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.
Files changed (179) hide show
  1. {structured_eval-0.2.0 → structured_eval-0.3.0}/PKG-INFO +30 -9
  2. {structured_eval-0.2.0 → structured_eval-0.3.0}/README.md +26 -7
  3. {structured_eval-0.2.0 → structured_eval-0.3.0}/pyproject.toml +45 -11
  4. structured_eval-0.3.0/structured_eval/__init__.py +20 -0
  5. structured_eval-0.3.0/structured_eval/alignment/__init__.py +29 -0
  6. structured_eval-0.3.0/structured_eval/alignment/base.py +141 -0
  7. structured_eval-0.3.0/structured_eval/alignment/by_index.py +43 -0
  8. structured_eval-0.3.0/structured_eval/alignment/by_key.py +138 -0
  9. structured_eval-0.3.0/structured_eval/alignment/factory.py +44 -0
  10. structured_eval-0.3.0/structured_eval/alignment/hungarian.py +237 -0
  11. structured_eval-0.3.0/structured_eval/api.py +158 -0
  12. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/engine/__init__.py +6 -0
  13. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/engine/aggregator.py +61 -4
  14. structured_eval-0.3.0/structured_eval/engine/evaluator.py +157 -0
  15. structured_eval-0.3.0/structured_eval/engine/metric_runner.py +127 -0
  16. structured_eval-0.3.0/structured_eval/engine/parser.py +77 -0
  17. structured_eval-0.3.0/structured_eval/engine/report_builder.py +138 -0
  18. structured_eval-0.3.0/structured_eval/engine/tree_builder.py +571 -0
  19. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/formats/__init__.py +6 -0
  20. structured_eval-0.3.0/structured_eval/formats/base.py +45 -0
  21. structured_eval-0.3.0/structured_eval/formats/json_parser.py +79 -0
  22. structured_eval-0.3.0/structured_eval/formats/yaml_parser.py +43 -0
  23. structured_eval-0.3.0/structured_eval/integrations/__init__.py +11 -0
  24. structured_eval-0.3.0/structured_eval/integrations/_adapter.py +87 -0
  25. structured_eval-0.3.0/structured_eval/integrations/deepeval.py +131 -0
  26. structured_eval-0.3.0/structured_eval/integrations/langsmith.py +148 -0
  27. structured_eval-0.3.0/structured_eval/llm/__init__.py +36 -0
  28. structured_eval-0.3.0/structured_eval/llm/base.py +169 -0
  29. structured_eval-0.3.0/structured_eval/llm/callable.py +66 -0
  30. structured_eval-0.3.0/structured_eval/llm/chat_model.py +145 -0
  31. structured_eval-0.3.0/structured_eval/llm/exceptions.py +52 -0
  32. structured_eval-0.3.0/structured_eval/llm/factory.py +110 -0
  33. structured_eval-0.3.0/structured_eval/llm/litellm.py +173 -0
  34. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/__init__.py +10 -0
  35. structured_eval-0.3.0/structured_eval/metrics/array_accuracy.py +45 -0
  36. structured_eval-0.3.0/structured_eval/metrics/array_cardinality.py +39 -0
  37. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_exact_match.py +25 -6
  38. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_f1.py +22 -1
  39. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_jaccard_similarity.py +29 -8
  40. structured_eval-0.3.0/structured_eval/metrics/array_precision.py +68 -0
  41. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_prf1.py +30 -3
  42. structured_eval-0.3.0/structured_eval/metrics/array_recall.py +65 -0
  43. structured_eval-0.3.0/structured_eval/metrics/base.py +319 -0
  44. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/character_f1.py +31 -17
  45. structured_eval-0.3.0/structured_eval/metrics/composite_score.py +65 -0
  46. structured_eval-0.3.0/structured_eval/metrics/coverage_leaf_score.py +41 -0
  47. structured_eval-0.3.0/structured_eval/metrics/date_distance_score.py +85 -0
  48. structured_eval-0.3.0/structured_eval/metrics/exact.py +36 -0
  49. structured_eval-0.3.0/structured_eval/metrics/exponential_numeric_score.py +67 -0
  50. structured_eval-0.3.0/structured_eval/metrics/field_faithfulness.py +57 -0
  51. structured_eval-0.3.0/structured_eval/metrics/fuzzy.py +107 -0
  52. structured_eval-0.3.0/structured_eval/metrics/invoker.py +147 -0
  53. structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/__init__.py +23 -0
  54. structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/criteria.py +111 -0
  55. structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/metric.py +274 -0
  56. structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/prompt.py +63 -0
  57. structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/schemas.py +51 -0
  58. structured_eval-0.3.0/structured_eval/metrics/levenshtein.py +48 -0
  59. structured_eval-0.3.0/structured_eval/metrics/mean_score.py +42 -0
  60. structured_eval-0.3.0/structured_eval/metrics/numeric.py +116 -0
  61. structured_eval-0.3.0/structured_eval/metrics/numeric_closeness.py +50 -0
  62. structured_eval-0.3.0/structured_eval/metrics/object_accuracy.py +75 -0
  63. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/object_exact_match.py +21 -5
  64. structured_eval-0.3.0/structured_eval/metrics/object_f1.py +82 -0
  65. structured_eval-0.3.0/structured_eval/metrics/object_precision.py +81 -0
  66. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/object_prf1.py +37 -4
  67. structured_eval-0.3.0/structured_eval/metrics/object_recall.py +80 -0
  68. structured_eval-0.3.0/structured_eval/metrics/object_type_validity.py +55 -0
  69. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/overall_leaf_score.py +17 -5
  70. structured_eval-0.3.0/structured_eval/metrics/presence.py +35 -0
  71. structured_eval-0.3.0/structured_eval/metrics/regex_match.py +74 -0
  72. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/rule_pass_rate/__init__.py +2 -0
  73. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/rule_pass_rate/dsl.py +70 -12
  74. structured_eval-0.3.0/structured_eval/metrics/rule_pass_rate/engine.py +35 -0
  75. structured_eval-0.3.0/structured_eval/metrics/rule_pass_rate/metric.py +54 -0
  76. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/schema_validity/__init__.py +2 -0
  77. structured_eval-0.3.0/structured_eval/metrics/schema_validity/metric.py +60 -0
  78. structured_eval-0.3.0/structured_eval/metrics/schema_validity/validator.py +182 -0
  79. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/structural_similarity.py +16 -11
  80. structured_eval-0.3.0/structured_eval/metrics/token_f1.py +101 -0
  81. structured_eval-0.3.0/structured_eval/metrics/type_match.py +52 -0
  82. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/url_match.py +42 -21
  83. structured_eval-0.3.0/structured_eval/metrics/utils/__init__.py +8 -0
  84. structured_eval-0.3.0/structured_eval/metrics/utils/array.py +39 -0
  85. structured_eval-0.3.0/structured_eval/metrics/utils/calculate.py +95 -0
  86. structured_eval-0.3.0/structured_eval/metrics/utils/null.py +10 -0
  87. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/utils/number.py +21 -11
  88. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/utils/object_utils.py +25 -19
  89. structured_eval-0.3.0/structured_eval/metrics/utils/value.py +23 -0
  90. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/models/__init__.py +17 -13
  91. structured_eval-0.3.0/structured_eval/models/config.py +196 -0
  92. structured_eval-0.3.0/structured_eval/models/context.py +34 -0
  93. structured_eval-0.3.0/structured_eval/models/metrics/__init__.py +19 -0
  94. structured_eval-0.3.0/structured_eval/models/metrics/collection.py +78 -0
  95. structured_eval-0.3.0/structured_eval/models/metrics/judge.py +37 -0
  96. structured_eval-0.3.0/structured_eval/models/metrics/result.py +79 -0
  97. structured_eval-0.3.0/structured_eval/models/nodes/__init__.py +20 -0
  98. structured_eval-0.3.0/structured_eval/models/nodes/array_node.py +57 -0
  99. structured_eval-0.3.0/structured_eval/models/nodes/base.py +146 -0
  100. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/models/nodes/object_node.py +7 -3
  101. structured_eval-0.3.0/structured_eval/models/nodes/scalar.py +13 -0
  102. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/models/result.py +120 -58
  103. structured_eval-0.3.0/structured_eval/models/sample.py +27 -0
  104. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/reporting/console.py +44 -6
  105. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/__init__.py +6 -0
  106. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/flatten.py +29 -15
  107. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/paths.py +14 -7
  108. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/structured_diff.py +47 -19
  109. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/PKG-INFO +30 -9
  110. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/SOURCES.txt +17 -1
  111. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/requires.txt +4 -1
  112. structured_eval-0.2.0/structured_eval/__init__.py +0 -27
  113. structured_eval-0.2.0/structured_eval/alignment/__init__.py +0 -15
  114. structured_eval-0.2.0/structured_eval/alignment/base.py +0 -40
  115. structured_eval-0.2.0/structured_eval/alignment/by_index.py +0 -24
  116. structured_eval-0.2.0/structured_eval/alignment/by_key.py +0 -73
  117. structured_eval-0.2.0/structured_eval/alignment/factory.py +0 -28
  118. structured_eval-0.2.0/structured_eval/alignment/hungarian.py +0 -156
  119. structured_eval-0.2.0/structured_eval/api.py +0 -79
  120. structured_eval-0.2.0/structured_eval/engine/evaluator.py +0 -72
  121. structured_eval-0.2.0/structured_eval/engine/metric_runner.py +0 -69
  122. structured_eval-0.2.0/structured_eval/engine/parser.py +0 -42
  123. structured_eval-0.2.0/structured_eval/engine/report_builder.py +0 -68
  124. structured_eval-0.2.0/structured_eval/engine/tree_builder.py +0 -319
  125. structured_eval-0.2.0/structured_eval/formats/base.py +0 -19
  126. structured_eval-0.2.0/structured_eval/formats/json_parser.py +0 -44
  127. structured_eval-0.2.0/structured_eval/formats/yaml_parser.py +0 -24
  128. structured_eval-0.2.0/structured_eval/integrations/__init__.py +0 -11
  129. structured_eval-0.2.0/structured_eval/integrations/_adapter.py +0 -47
  130. structured_eval-0.2.0/structured_eval/integrations/deepeval.py +0 -74
  131. structured_eval-0.2.0/structured_eval/integrations/langsmith.py +0 -90
  132. structured_eval-0.2.0/structured_eval/metrics/array_accuracy.py +0 -28
  133. structured_eval-0.2.0/structured_eval/metrics/array_cardinality.py +0 -27
  134. structured_eval-0.2.0/structured_eval/metrics/array_precision.py +0 -38
  135. structured_eval-0.2.0/structured_eval/metrics/array_recall.py +0 -37
  136. structured_eval-0.2.0/structured_eval/metrics/base.py +0 -161
  137. structured_eval-0.2.0/structured_eval/metrics/composite_score.py +0 -47
  138. structured_eval-0.2.0/structured_eval/metrics/coverage_leaf_score.py +0 -29
  139. structured_eval-0.2.0/structured_eval/metrics/date_distance_score.py +0 -69
  140. structured_eval-0.2.0/structured_eval/metrics/exact.py +0 -21
  141. structured_eval-0.2.0/structured_eval/metrics/exponential_numeric_score.py +0 -52
  142. structured_eval-0.2.0/structured_eval/metrics/field_faithfulness.py +0 -38
  143. structured_eval-0.2.0/structured_eval/metrics/fuzzy.py +0 -70
  144. structured_eval-0.2.0/structured_eval/metrics/invoker.py +0 -90
  145. structured_eval-0.2.0/structured_eval/metrics/levenshtein.py +0 -21
  146. structured_eval-0.2.0/structured_eval/metrics/mean_score.py +0 -31
  147. structured_eval-0.2.0/structured_eval/metrics/numeric.py +0 -91
  148. structured_eval-0.2.0/structured_eval/metrics/numeric_closeness.py +0 -39
  149. structured_eval-0.2.0/structured_eval/metrics/object_accuracy.py +0 -49
  150. structured_eval-0.2.0/structured_eval/metrics/object_f1.py +0 -49
  151. structured_eval-0.2.0/structured_eval/metrics/object_precision.py +0 -51
  152. structured_eval-0.2.0/structured_eval/metrics/object_recall.py +0 -46
  153. structured_eval-0.2.0/structured_eval/metrics/object_type_validity.py +0 -35
  154. structured_eval-0.2.0/structured_eval/metrics/presence.py +0 -22
  155. structured_eval-0.2.0/structured_eval/metrics/regex_match.py +0 -57
  156. structured_eval-0.2.0/structured_eval/metrics/rule_pass_rate/engine.py +0 -24
  157. structured_eval-0.2.0/structured_eval/metrics/rule_pass_rate/metric.py +0 -34
  158. structured_eval-0.2.0/structured_eval/metrics/schema_validity/metric.py +0 -38
  159. structured_eval-0.2.0/structured_eval/metrics/schema_validity/validator.py +0 -119
  160. structured_eval-0.2.0/structured_eval/metrics/token_f1.py +0 -83
  161. structured_eval-0.2.0/structured_eval/metrics/type_match.py +0 -35
  162. structured_eval-0.2.0/structured_eval/metrics/utils/__init__.py +0 -12
  163. structured_eval-0.2.0/structured_eval/metrics/utils/array.py +0 -31
  164. structured_eval-0.2.0/structured_eval/metrics/utils/calculate.py +0 -72
  165. structured_eval-0.2.0/structured_eval/metrics/utils/null.py +0 -20
  166. structured_eval-0.2.0/structured_eval/models/config.py +0 -124
  167. structured_eval-0.2.0/structured_eval/models/context.py +0 -25
  168. structured_eval-0.2.0/structured_eval/models/metric_result.py +0 -121
  169. structured_eval-0.2.0/structured_eval/models/nodes/__init__.py +0 -13
  170. structured_eval-0.2.0/structured_eval/models/nodes/array_node.py +0 -32
  171. structured_eval-0.2.0/structured_eval/models/nodes/base.py +0 -113
  172. structured_eval-0.2.0/structured_eval/models/nodes/scalar.py +0 -14
  173. structured_eval-0.2.0/structured_eval/models/sample.py +0 -19
  174. {structured_eval-0.2.0 → structured_eval-0.3.0}/LICENSE +0 -0
  175. {structured_eval-0.2.0 → structured_eval-0.3.0}/setup.cfg +0 -0
  176. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/py.typed +0 -0
  177. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/reporting/__init__.py +0 -0
  178. {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/dependency_links.txt +0 -0
  179. {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.2.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
- Learn more [field faithfulness](docs/metrics/catalog/field_faithfulness.md).
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 FieldFaithfulness
351
+ from structured_eval.models import EvalConfig, ObjectFieldConfig
352
+ from structured_eval.metrics import JudgeFaithfulness
350
353
 
351
354
  report = evaluate(
352
- actual={"title": "Introduction to Python", "duration_hours": 40},
355
+ actual={
356
+ "course": "Introduction to Python",
357
+ "instructor": {"name": "Dr. Rivera", "title": "professor"},
358
+ },
353
359
  expected=None,
354
- config=EvalConfig(metrics=[FieldFaithfulness()]),
355
- source="Course: Introduction to Python. Duration: 12 hours.",
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["field_faithfulness"].by_path # {'title': 1.0, 'duration_hours': 0.0 ← 40 ≠ 12}
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
- Learn more [field faithfulness](docs/metrics/catalog/field_faithfulness.md).
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 FieldFaithfulness
306
+ from structured_eval.models import EvalConfig, ObjectFieldConfig
307
+ from structured_eval.metrics import JudgeFaithfulness
307
308
 
308
309
  report = evaluate(
309
- actual={"title": "Introduction to Python", "duration_hours": 40},
310
+ actual={
311
+ "course": "Introduction to Python",
312
+ "instructor": {"name": "Dr. Rivera", "title": "professor"},
313
+ },
310
314
  expected=None,
311
- config=EvalConfig(metrics=[FieldFaithfulness()]),
312
- source="Course: Introduction to Python. Duration: 12 hours.",
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["field_faithfulness"].by_path # {'title': 1.0, 'duration_hours': 0.0 ← 40 ≠ 12}
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.2.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.26.0",
71
- "types-jsonschema>=4.26.0.20260518",
73
+ "jsonschema>=4.20.0",
74
+ "types-jsonschema>=4.20.0.20260518",
72
75
  "jsonpath-ng>=1.6",
73
- "deepdiff>=9.1.0",
74
- "scipy>=1.12",
75
- "scipy-stubs>=1.12",
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 = ["jsonpath_ng.*", "deepeval.*", "langsmith.*", "deepdiff.*", "rapidfuzz.*"]
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: host-framework adapters (deepeval/langsmith)",
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 = 90
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)