beefcot 1.0.4__tar.gz → 1.1.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 (140) hide show
  1. {beefcot-1.0.4 → beefcot-1.1.0}/PKG-INFO +1 -1
  2. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/__init__.py +1 -1
  3. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/figures.py +1 -1
  4. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cutting_plan/economics.py +161 -37
  5. beefcot-1.1.0/src/beefcot/cutting_plan/eligibility.py +158 -0
  6. beefcot-1.1.0/src/beefcot/cutting_plan/receipt.py +123 -0
  7. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cutting_plan/run.py +153 -25
  8. beefcot-1.1.0/src/beefcot/cutting_plan/solver.py +179 -0
  9. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/app.py +295 -27
  10. beefcot-1.1.0/src/beefcot/service/errors.py +19 -0
  11. beefcot-1.1.0/src/beefcot/service/history.py +250 -0
  12. beefcot-1.1.0/src/beefcot/service/job_process.py +90 -0
  13. beefcot-1.1.0/src/beefcot/service/model_evidence.py +26 -0
  14. beefcot-1.1.0/src/beefcot/service/plan_analysis.py +246 -0
  15. beefcot-1.1.0/src/beefcot/service/plan_comparison.py +133 -0
  16. beefcot-1.1.0/src/beefcot/service/plan_editor.py +237 -0
  17. beefcot-1.1.0/src/beefcot/service/plan_evidence.py +162 -0
  18. beefcot-1.1.0/src/beefcot/service/plan_export.py +122 -0
  19. beefcot-1.1.0/src/beefcot/service/plan_figures.py +41 -0
  20. beefcot-1.1.0/src/beefcot/service/plan_inputs.py +313 -0
  21. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/plans.py +72 -22
  22. beefcot-1.1.0/src/beefcot/service/purchase_ceiling.py +73 -0
  23. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/runner.py +20 -19
  24. beefcot-1.1.0/src/beefcot/service/saved_plan.py +203 -0
  25. beefcot-1.1.0/src/beefcot/service/views.py +509 -0
  26. beefcot-1.0.4/src/beefcot/cutting_plan/solver.py +0 -104
  27. beefcot-1.0.4/src/beefcot/service/errors.py +0 -11
  28. {beefcot-1.0.4 → beefcot-1.1.0}/.gitignore +0 -0
  29. {beefcot-1.0.4 → beefcot-1.1.0}/README.pypi.md +0 -0
  30. {beefcot-1.0.4 → beefcot-1.1.0}/pyproject.toml +0 -0
  31. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/__main__.py +0 -0
  32. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/anomaly.py +0 -0
  33. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/chain.py +0 -0
  34. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/__init__.py +0 -0
  35. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/anomaly.py +0 -0
  36. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/anomaly_figures.py +0 -0
  37. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/bins.py +0 -0
  38. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/cut_tree.py +0 -0
  39. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/forest.py +0 -0
  40. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/report.py +0 -0
  41. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/review.py +0 -0
  42. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/run.py +0 -0
  43. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/spec.py +0 -0
  44. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/checks/table.py +0 -0
  45. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cli.py +0 -0
  46. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cli_presentation.py +0 -0
  47. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/cutting_plan/__init__.py +0 -0
  48. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/__init__.py +0 -0
  49. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/analysis.py +0 -0
  50. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/fits.py +0 -0
  51. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/report.py +0 -0
  52. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/run.py +0 -0
  53. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/sources.py +0 -0
  54. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/decomposition/spec.py +0 -0
  55. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/__init__.py +0 -0
  56. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/analysis.py +0 -0
  57. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/methods.py +0 -0
  58. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/plots.py +0 -0
  59. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/report.py +0 -0
  60. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/run.py +0 -0
  61. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/sources.py +0 -0
  62. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/diagnostics/spec.py +0 -0
  63. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/__init__.py +0 -0
  64. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/expression.py +0 -0
  65. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/functional.py +0 -0
  66. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/learner_registry.py +0 -0
  67. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/learners.py +0 -0
  68. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/rows.py +0 -0
  69. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/runtime.py +0 -0
  70. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/topology.py +0 -0
  71. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/transforms.py +0 -0
  72. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model/types.py +0 -0
  73. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/__init__.py +0 -0
  74. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/blocks.py +0 -0
  75. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/card.py +0 -0
  76. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/documents.py +0 -0
  77. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/export.py +0 -0
  78. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/fitted.py +0 -0
  79. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/library.py +0 -0
  80. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/model_format/schema.py +0 -0
  81. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/prediction/__init__.py +0 -0
  82. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/prediction/run.py +0 -0
  83. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/__init__.py +0 -0
  84. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/network.py +0 -0
  85. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/rf/__init__.py +0 -0
  86. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/rf/loop.py +0 -0
  87. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/producers/rf/relevance.py +0 -0
  88. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/project.py +0 -0
  89. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/project_registry.py +0 -0
  90. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/registry.py +0 -0
  91. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/__init__.py +0 -0
  92. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/anomaly.py +0 -0
  93. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/assembly.py +0 -0
  94. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/assembly_network.py +0 -0
  95. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/candidates.py +0 -0
  96. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/exhaustive.py +0 -0
  97. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/exhaustive_parallel.py +0 -0
  98. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/exhaustive_storage.py +0 -0
  99. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/factory.py +0 -0
  100. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/mapping_review.py +0 -0
  101. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/mapping_space.py +0 -0
  102. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/provenance.py +0 -0
  103. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf.py +0 -0
  104. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_checkpoint.py +0 -0
  105. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_controller.py +0 -0
  106. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_effort.py +0 -0
  107. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_equivalence.py +0 -0
  108. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_policy.py +0 -0
  109. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_selection.py +0 -0
  110. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_settings.py +0 -0
  111. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rerf_warm_start.py +0 -0
  112. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/residual.py +0 -0
  113. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/rf_expression.py +0 -0
  114. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/run.py +0 -0
  115. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/spec.py +0 -0
  116. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/regression/table.py +0 -0
  117. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/__init__.py +0 -0
  118. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/metrics.py +0 -0
  119. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/plots.py +0 -0
  120. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/regression_space.py +0 -0
  121. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/run.py +0 -0
  122. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/reports/style.py +0 -0
  123. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/resources/cut_tree.default.json +0 -0
  124. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/resources/rerf_space.default.yaml +0 -0
  125. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/__init__.py +0 -0
  126. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/expression_ast.py +0 -0
  127. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/loader.py +0 -0
  128. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/model.py +0 -0
  129. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/checks-3.schema.json +0 -0
  130. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/model-3.schema.json +0 -0
  131. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/predictors-3.schema.json +0 -0
  132. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/request-3.schema.json +0 -0
  133. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/runtime/schemas/result-3.schema.json +0 -0
  134. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/__init__.py +0 -0
  135. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/auth.py +0 -0
  136. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/projects.py +0 -0
  137. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/server.py +0 -0
  138. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/service/studies.py +0 -0
  139. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/workspace.py +0 -0
  140. {beefcot-1.0.4 → beefcot-1.1.0}/src/beefcot/yamlio.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: beefcot
3
- Version: 1.0.4
3
+ Version: 1.1.0
4
4
  Summary: Carcass yield models and cutting plans: one YAML in, one delivered model out
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: fastapi>=0.115
@@ -24,7 +24,7 @@ from __future__ import annotations
24
24
  from pathlib import Path
25
25
  from typing import Any
26
26
 
27
- __version__ = "1.0.4" # the one place it is written; CHANGELOG.md records each version
27
+ __version__ = "1.1.0" # the one place it is written; CHANGELOG.md records each version
28
28
 
29
29
  __all__ = ["__version__", "fit", "predict", "plan", "check", "decompose", "run", "load_model"]
30
30
 
@@ -79,7 +79,7 @@ def plot_config(
79
79
  "DataSet": [{"name": "animals", "path": "../animals.csv", "type": "csv"},
80
80
  {"name": "cells", "path": "../cells.csv", "type": "csv"}],
81
81
  "Figures": figures,
82
- "output": {"dir": ".", "formats": ["png"]},
82
+ "output": {"dir": ".", "formats": ["svg", "png"]},
83
83
  }
84
84
 
85
85
 
@@ -6,13 +6,14 @@ built from three things, and only the first is ours:
6
6
 
7
7
  product yield what each carcass gives for each product <- Model.predict_table
8
8
  prices what each product sells for <- scenario
9
- pattern costs labour rate and processing speed, capacity <- scenario
9
+ pattern costs labor rate, processing speed and packaging <- scenario
10
10
 
11
11
  This module builds those matrices and stops there. The mixed-integer solve is a separate
12
12
  concern: ``solve`` hands the matrices to HiGHS (``plan/solver.py``).
13
13
 
14
14
  Units, as the reference optimizer defines them: ``process_speed`` is carcasses per **minute**
15
- and ``hourly_labor_cost`` is money per hour, so labour per carcass is hourly cost / speed / 60.
15
+ and ``hourly_labor_cost`` is money per hour, so labor per carcass is hourly cost / speed / 60.
16
+ The optional ``packaging_cost_per_head`` is already a per-carcass cost.
16
17
 
17
18
  Coverage is explicit. The delivered tree predicts the 16 measured cuts (and their totals);
18
19
  a pattern may name products it does not cover, and pretending otherwise is how a plan ends
@@ -31,16 +32,40 @@ enough of it for the level to mean something.
31
32
  from __future__ import annotations
32
33
 
33
34
  from dataclasses import dataclass, field
35
+ import math
34
36
  from typing import Any, Mapping, Sequence
35
37
 
36
38
  import numpy as np
37
39
  import pandas as pd
38
40
 
39
41
  from beefcot.runtime import Model
42
+ from beefcot.cutting_plan.eligibility import Eligibility, Rule, evaluate, parse_rules
40
43
 
41
44
  UNCOVERED_POLICIES = ("error", "zero", "external")
42
45
  # Does every carcass have to be processed, or may a carcass be left out of the plan?
43
46
  ASSIGNMENT_RULES = ("exactly_one", "at_most_one")
47
+ PURCHASE_PRICE_BASES = ("per_head", "per_kg")
48
+
49
+
50
+ def amount(value: Any, context: str, *, positive: bool = False) -> float:
51
+ if isinstance(value, bool):
52
+ raise ValueError(f"{context}: expected a finite number")
53
+ try:
54
+ number = float(value)
55
+ except (TypeError, ValueError) as failure:
56
+ raise ValueError(f"{context}: expected a finite number") from failure
57
+ if not math.isfinite(number) or (number <= 0 if positive else number < 0):
58
+ raise ValueError(f"{context}: must be finite and {'positive' if positive else 'nonnegative'}")
59
+ return number
60
+
61
+
62
+ def whole(value: Any, context: str) -> int | None:
63
+ if value is None:
64
+ return None
65
+ number = amount(value, context)
66
+ if number != math.floor(number) or number > 2**53 - 1:
67
+ raise ValueError(f"{context}: must be an exactly representable whole number")
68
+ return int(number)
44
69
 
45
70
 
46
71
  @dataclass(frozen=True)
@@ -57,6 +82,21 @@ class Pattern:
57
82
  # scenario's list. A slower, closer trim sells the same cut for more; a fast break sells
58
83
  # it for less. Products left out here are sold at the list price.
59
84
  prices: Mapping[str, float] = field(default_factory=dict)
85
+ eligibility: tuple[Rule, ...] = ()
86
+ packaging_cost_per_head: float = 0.0
87
+
88
+ def __post_init__(self) -> None:
89
+ if not self.name.strip() or not self.products:
90
+ raise ValueError(f"pattern {self.name!r}: name and products must be nonempty")
91
+ amount(self.process_speed, f"pattern {self.name}: process_speed", positive=True)
92
+ amount(self.hourly_labor_cost, f"pattern {self.name}: hourly_labor_cost")
93
+ amount(self.packaging_cost_per_head, f"pattern {self.name}: packaging_cost_per_head")
94
+ for key in ("min_carcasses", "max_carcasses"):
95
+ whole(getattr(self, key), f"pattern {self.name}: {key}")
96
+ if self.min_carcasses is not None and self.max_carcasses is not None and self.min_carcasses > self.max_carcasses:
97
+ raise ValueError(f"pattern {self.name}: min_carcasses exceeds max_carcasses")
98
+ for product, price in self.prices.items():
99
+ amount(price, f"pattern {self.name}: prices.{product}")
60
100
 
61
101
  @property
62
102
  def labor_cost_per_carcass(self) -> float:
@@ -70,7 +110,7 @@ class Pattern:
70
110
  if not isinstance(payload, Mapping):
71
111
  raise TypeError("a pattern must be a mapping")
72
112
  allowed = {"name", "products", "process_speed", "hourly_labor_cost", "min_carcasses",
73
- "max_carcasses", "prices"}
113
+ "max_carcasses", "prices", "eligibility", "packaging_cost_per_head"}
74
114
  unknown = sorted(set(payload) - allowed)
75
115
  if unknown:
76
116
  raise ValueError(f"pattern {payload.get('name')!r}: unknown keys={unknown}; allowed={sorted(allowed)}")
@@ -78,14 +118,19 @@ class Pattern:
78
118
  missing = [key for key in required if key not in payload]
79
119
  if missing:
80
120
  raise ValueError(f"pattern {payload.get('name', '?')!r}: missing keys={missing}")
121
+ if not isinstance(payload["products"], (list, tuple)):
122
+ raise ValueError(f"pattern {payload['name']}: products must be a list")
81
123
  return cls(
82
124
  name=str(payload["name"]),
83
125
  products=tuple(str(item) for item in payload["products"]),
84
- process_speed=float(payload["process_speed"]),
85
- hourly_labor_cost=float(payload["hourly_labor_cost"]),
86
- min_carcasses=None if payload.get("min_carcasses") is None else int(payload["min_carcasses"]),
87
- max_carcasses=None if payload.get("max_carcasses") is None else int(payload["max_carcasses"]),
88
- prices={str(key): float(value) for key, value in dict(payload.get("prices") or {}).items()},
126
+ process_speed=amount(payload["process_speed"], f"pattern {payload['name']}: process_speed", positive=True),
127
+ hourly_labor_cost=amount(payload["hourly_labor_cost"], f"pattern {payload['name']}: hourly_labor_cost"),
128
+ packaging_cost_per_head=amount(payload.get("packaging_cost_per_head", 0),
129
+ f"pattern {payload['name']}: packaging_cost_per_head"),
130
+ min_carcasses=whole(payload.get("min_carcasses"), f"pattern {payload['name']}: min_carcasses"),
131
+ max_carcasses=whole(payload.get("max_carcasses"), f"pattern {payload['name']}: max_carcasses"),
132
+ eligibility=parse_rules(payload.get("eligibility", []), f"pattern {payload['name']} eligibility"),
133
+ prices={str(key): amount(value, f"pattern {payload['name']}: prices.{key}") for key, value in dict(payload.get("prices") or {}).items()},
89
134
  )
90
135
 
91
136
  def to_dict(self) -> dict[str, Any]:
@@ -94,9 +139,11 @@ class Pattern:
94
139
  "products": list(self.products),
95
140
  "process_speed": self.process_speed,
96
141
  "hourly_labor_cost": self.hourly_labor_cost,
142
+ **({"packaging_cost_per_head": self.packaging_cost_per_head} if self.packaging_cost_per_head else {}),
97
143
  "min_carcasses": self.min_carcasses,
98
144
  "max_carcasses": self.max_carcasses,
99
145
  "prices": dict(self.prices),
146
+ **({"eligibility": [rule.to_dict() for rule in self.eligibility]} if self.eligibility else {}),
100
147
  }
101
148
 
102
149
 
@@ -109,14 +156,50 @@ class ScenarioSpec:
109
156
  pieces_per_carcass: float = 2.0 # both sides; the tree predicts one side
110
157
  purchase_price_column: str | None = "grid_price"
111
158
  weight_column: str | None = None # the carcass weight the purchase price is per kg of
112
- purchase_price: float | None = None # a flat price per kg when the carcasses carry none
159
+ purchase_price: float | None = None # scalar fallback when the source column is absent
113
160
  available_column: str | None = "num_available"
114
161
  uncovered_products: str = "error"
115
162
  assignment: str = "exactly_one" # exactly_one | at_most_one
163
+ units: Mapping[str, str | None] = field(default_factory=dict)
164
+ purchase_price_basis: str = "per_kg"
165
+ purchase_price_column_basis: str = "per_kg"
166
+
167
+ @property
168
+ def yield_factor(self) -> float:
169
+ return 1.0 if self.units.get("yield") == "kg/carcass" else self.pieces_per_carcass
170
+
171
+ @property
172
+ def purchase_weight_factor(self) -> float:
173
+ return 1.0 if self.units.get("purchase_weight") == "kg/carcass" else self.pieces_per_carcass
174
+
175
+ def purchase_input(self, columns) -> tuple[str | None, str | None]:
176
+ if self.purchase_price_column and self.purchase_price_column in columns:
177
+ return self.purchase_price_column, self.purchase_price_column_basis
178
+ if self.purchase_price is not None:
179
+ return None, self.purchase_price_basis
180
+ return None, None
116
181
 
117
182
  def __post_init__(self) -> None:
183
+ for key in ("purchase_price_basis", "purchase_price_column_basis"):
184
+ if getattr(self, key) not in PURCHASE_PRICE_BASES:
185
+ raise ValueError(f"scenario.{key}: expected per_head or per_kg")
118
186
  if not self.patterns:
119
187
  raise ValueError("a scenario needs at least one pattern")
188
+ if len({pattern.name for pattern in self.patterns}) != len(self.patterns):
189
+ raise ValueError("scenario: pattern names must be unique")
190
+ amount(self.pieces_per_carcass, "scenario.pieces_per_carcass", positive=True)
191
+ for product, price in self.prices.items():
192
+ amount(price, f"scenario.prices.{product}")
193
+ if self.purchase_price is not None:
194
+ amount(self.purchase_price, "scenario.purchase_price")
195
+ if not isinstance(self.units, Mapping) or set(self.units) - {"yield", "purchase_weight", "currency"}:
196
+ raise ValueError("scenario.units: allowed keys are yield, purchase_weight, currency")
197
+ for key in ("yield", "purchase_weight"):
198
+ if self.units.get(key, "kg/side") not in {"kg/side", "kg/carcass"}:
199
+ raise ValueError(f"scenario.units.{key}: expected kg/side or kg/carcass")
200
+ currency = self.units.get("currency")
201
+ if currency is not None and (not isinstance(currency, str) or len(currency) != 3 or not currency.isascii() or not currency.isupper() or not currency.isalpha()):
202
+ raise ValueError("scenario.units.currency: expected a three-letter uppercase currency code")
120
203
  if self.assignment not in ASSIGNMENT_RULES:
121
204
  raise ValueError(f"assignment={self.assignment!r}; allowed={list(ASSIGNMENT_RULES)}")
122
205
  if self.uncovered_products not in UNCOVERED_POLICIES:
@@ -140,6 +223,9 @@ class ScenarioSpec:
140
223
  for pattern in self.patterns:
141
224
  for product in pattern.products:
142
225
  seen.setdefault(product, None)
226
+ for rule in pattern.eligibility:
227
+ if rule.kind == "product":
228
+ seen.setdefault(rule.name, None)
143
229
  return tuple(seen)
144
230
 
145
231
  @classmethod
@@ -149,6 +235,7 @@ class ScenarioSpec:
149
235
  allowed = {
150
236
  "patterns", "prices", "pieces_per_carcass", "purchase_price_column", "purchase_price",
151
237
  "weight_column", "available_column", "uncovered_products", "assignment",
238
+ "units", "purchase_price_basis", "purchase_price_column_basis",
152
239
  }
153
240
  unknown = sorted(set(payload) - allowed)
154
241
  if unknown:
@@ -158,14 +245,17 @@ class ScenarioSpec:
158
245
  raise ValueError(f"scenario: missing keys={missing}")
159
246
  return cls(
160
247
  patterns=tuple(Pattern.from_dict(item) for item in payload["patterns"]),
161
- prices={str(key): float(value) for key, value in dict(payload["prices"]).items()},
162
- pieces_per_carcass=float(payload.get("pieces_per_carcass", 2.0)),
248
+ prices={str(key): amount(value, f"scenario.prices.{key}") for key, value in dict(payload["prices"]).items()},
249
+ pieces_per_carcass=amount(payload.get("pieces_per_carcass", 2.0), "scenario.pieces_per_carcass", positive=True),
163
250
  purchase_price_column=payload.get("purchase_price_column", "grid_price"),
164
251
  weight_column=None if payload.get("weight_column") is None else str(payload["weight_column"]),
165
- purchase_price=None if payload.get("purchase_price") is None else float(payload["purchase_price"]),
252
+ purchase_price=None if payload.get("purchase_price") is None else amount(payload["purchase_price"], "scenario.purchase_price"),
166
253
  available_column=payload.get("available_column", "num_available"),
167
254
  uncovered_products=str(payload.get("uncovered_products", "error")),
168
255
  assignment=str(payload.get("assignment", "exactly_one")),
256
+ units=payload.get("units", {}),
257
+ purchase_price_basis=payload.get("purchase_price_basis", "per_kg"),
258
+ purchase_price_column_basis=payload.get("purchase_price_column_basis", "per_kg"),
169
259
  )
170
260
 
171
261
  def to_dict(self) -> dict[str, Any]:
@@ -179,6 +269,9 @@ class ScenarioSpec:
179
269
  "available_column": self.available_column,
180
270
  "uncovered_products": self.uncovered_products,
181
271
  "assignment": self.assignment,
272
+ "units": dict(self.units),
273
+ **{key: getattr(self, key) for key in ("purchase_price_basis", "purchase_price_column_basis")
274
+ if getattr(self, key) != "per_kg"},
182
275
  }
183
276
 
184
277
 
@@ -200,6 +293,8 @@ class CuttingPlanInputs:
200
293
  # pattern -> the measured cuts it sells once its totals are counted as their cuts; two
201
294
  # patterns are a like-for-like choice only when these are the same
202
295
  pattern_cuts: dict[str, tuple[str, ...]] = field(default_factory=dict)
296
+ eligibility: Eligibility | None = None
297
+ animal_ids: tuple[str, ...] = ()
203
298
 
204
299
 
205
300
  @dataclass
@@ -212,6 +307,12 @@ class PlanResult:
212
307
  per_pattern_count: dict[str, int]
213
308
  solver: str
214
309
  seconds: float | None = None
310
+ outcome: str = "error"
311
+ bound: float | None = None # upper bound, in the maximisation problem's sign
312
+ gap: float | None = None
313
+ termination: str | None = None
314
+ verification: dict[str, Any] = field(default_factory=dict)
315
+ constraint_rows: list[dict[str, Any]] = field(default_factory=list)
215
316
 
216
317
 
217
318
  # The one solver: HiGHS through SciPy (plan/solver.py). A plan YAML may name it, nothing else.
@@ -224,6 +325,8 @@ def build_inputs(
224
325
  carcasses: pd.DataFrame,
225
326
  *,
226
327
  external_yield: pd.DataFrame | None = None,
328
+ eligibility_source: pd.DataFrame | None = None,
329
+ animal_ids: tuple[str, ...] = (),
227
330
  ) -> CuttingPlanInputs:
228
331
  """Predict the carcasses, then build the income, cost and profit matrices.
229
332
 
@@ -251,21 +354,27 @@ def build_inputs(
251
354
  used.append(model)
252
355
  warnings.append((model, predicted.attrs.get("warnings", {})))
253
356
  for product in needed:
357
+ contract_units = {str(item['code']): item.get('unit') for item in model.contract['products']}
358
+ members = model.totals.get(product, (product,))
359
+ if any(contract_units.get(member) != "kg" for member in members):
360
+ raise ValueError(f"model {model.id}, product {product}: cutting plans require model output in kg")
254
361
  coverage[product] = model.id
255
362
  frames[product] = pd.Series(predicted[product].to_numpy(dtype=float), index=index)
256
363
  for product in scenario.products:
257
364
  if product in frames:
258
365
  continue
259
366
  if external_yield is not None and product in external_yield.columns:
367
+ if len(external_yield) != len(carcasses):
368
+ raise ValueError(f"external yield product {product}: expected {len(carcasses)} rows, got {len(external_yield)}")
260
369
  coverage[product] = "external"
261
- frames[product] = pd.Series(external_yield[product].astype(float).to_numpy(), index=index)
370
+ frames[product] = pd.Series(_amounts(external_yield, product, what="external product yield"), index=index)
262
371
  continue
263
372
  uncovered.append(product)
264
- if scenario.uncovered_products == "error":
373
+ if scenario.uncovered_products in {"error", "external"}:
265
374
  continue
266
375
  coverage[product] = "zero"
267
376
  frames[product] = pd.Series(np.zeros(len(carcasses)), index=index)
268
- if uncovered and scenario.uncovered_products == "error":
377
+ if uncovered and scenario.uncovered_products in {"error", "external"}:
269
378
  raise ValueError(
270
379
  "no model given predicts these products and no external yield was "
271
380
  f"given: {sorted(uncovered)}. Either supply them (uncovered_products: external) "
@@ -278,39 +387,30 @@ def build_inputs(
278
387
  pattern_cuts = {pattern.name: _cuts_of(pattern, members_of) for pattern in scenario.patterns}
279
388
 
280
389
  product_yield = pd.DataFrame(frames, index=index)
390
+ for product in product_yield:
391
+ _amounts(product_yield, product, what="predicted product yield")
281
392
  income = pd.DataFrame(index=index)
282
393
  labor = pd.DataFrame(index=index)
283
394
  for pattern in scenario.patterns:
284
395
  money = pd.Series(np.zeros(len(carcasses)), index=index)
285
396
  for product in pattern.products:
286
397
  price = float(pattern.prices.get(product, scenario.prices.get(product, 0.0)))
287
- money = money + product_yield[product] * price * scenario.pieces_per_carcass
398
+ money = money + product_yield[product] * price * scenario.yield_factor
288
399
  income[pattern.name] = money
289
400
  labor[pattern.name] = pattern.labor_cost_per_carcass
290
- # An unreadable weight or price would make that carcass's profit NaN, which the solver
291
- # cannot take and would report without saying which row; so every row is checked here.
292
- if scenario.purchase_price_column and scenario.purchase_price_column in carcasses.columns:
293
- price = _amounts(carcasses, scenario.purchase_price_column, what="the purchase price per kg")
294
- elif scenario.purchase_price is not None:
295
- price = np.full(len(carcasses), float(scenario.purchase_price))
296
- else:
297
- price = None # nothing is paid for, so the weight is not needed either
298
- if price is None:
299
- purchase = pd.Series(np.zeros(len(carcasses)), index=index)
300
- else:
301
- if not scenario.weight_column:
302
- raise ValueError("a purchase price needs scenario.weight_column: the carcass weight it is per kg of")
303
- weight = _amounts(carcasses, scenario.weight_column, what="the side weight the purchase cost is priced on")
304
- purchase = pd.Series(weight * price * scenario.pieces_per_carcass, index=index)
401
+ purchase = pd.Series(purchase_costs(carcasses, scenario), index=index)
305
402
  profit = income.subtract(labor, axis=0).subtract(purchase, axis=0)
403
+ for pattern in scenario.patterns:
404
+ if pattern.packaging_cost_per_head:
405
+ profit[pattern.name] -= float(pattern.packaging_cost_per_head)
306
406
  if scenario.available_column and scenario.available_column in carcasses.columns:
307
407
  raw = carcasses[scenario.available_column]
308
408
  counts = pd.to_numeric(raw, errors="coerce")
309
409
  # Silently reading an unreadable count as zero would drop that carcass from the plan
310
410
  # without a word, and a fractional one cannot be assigned at all (the solver would call
311
411
  # the plan infeasible and blame the pattern limits), so both are refused here.
312
- whole = counts.notna() & (counts >= 0) & (counts == np.floor(counts))
313
- bad = [int(i) for i, ok in enumerate(whole) if not ok]
412
+ valid_counts = counts.notna() & np.isfinite(counts) & (counts >= 0) & (counts <= 2**53 - 1) & (counts == np.floor(counts))
413
+ bad = [int(i) for i, ok in enumerate(valid_counts) if not ok]
314
414
  if bad:
315
415
  shown = ", ".join(f"row {i}: {_plain(raw.iloc[i])!r}" for i in bad[:5])
316
416
  raise ValueError(
@@ -346,6 +446,8 @@ def build_inputs(
346
446
  "pieces_per_carcass": scenario.pieces_per_carcass,
347
447
  },
348
448
  pattern_cuts=pattern_cuts,
449
+ eligibility=evaluate(scenario, carcasses if eligibility_source is None else eligibility_source, product_yield, coverage),
450
+ animal_ids=animal_ids,
349
451
  )
350
452
 
351
453
 
@@ -373,6 +475,22 @@ def _cuts_of(pattern: Pattern, members_of: Mapping[str, Sequence[str]]) -> tuple
373
475
  return tuple(sorted(sold))
374
476
 
375
477
 
478
+ def purchase_costs(carcasses: pd.DataFrame, scenario: ScenarioSpec) -> np.ndarray:
479
+ """Charge the known quote per head, or price its declared purchase kilograms."""
480
+ column, basis = scenario.purchase_input(carcasses.columns)
481
+ if basis is None:
482
+ return np.zeros(len(carcasses))
483
+ unit = "per head" if basis == "per_head" else "per kg"
484
+ prices = (_amounts(carcasses, column, what=f"the purchase price {unit}") if column
485
+ else np.full(len(carcasses), float(scenario.purchase_price)))
486
+ if basis == "per_head":
487
+ return prices
488
+ if not scenario.weight_column:
489
+ raise ValueError("a purchase price per kg needs scenario.weight_column")
490
+ weight = _amounts(carcasses, scenario.weight_column, what="the weight the purchase cost is priced on")
491
+ return weight * prices * scenario.purchase_weight_factor
492
+
493
+
376
494
  def _amounts(carcasses: pd.DataFrame, column: str, *, what: str) -> np.ndarray:
377
495
  """A money-relevant column as numbers, refusing the rows that are not a finite amount >= 0."""
378
496
  if column not in carcasses.columns:
@@ -418,10 +536,11 @@ def random_assignment_comparison(
418
536
  for pattern in inputs.patterns
419
537
  )
420
538
  solved = assignment is not None
539
+ comparable = inputs.eligibility is None
421
540
  if solved:
422
541
  counts = assignment.reindex(index=profit.index, columns=patterns).fillna(0.0).astype(float)
423
542
  chosen_profit = (profit * counts).sum(axis=1)
424
- advantage = chosen_profit - expected_profit
543
+ advantage = chosen_profit - expected_profit if comparable else pd.Series(np.nan, index=profit.index)
425
544
  else:
426
545
  chosen_profit = pd.Series(np.nan, index=profit.index)
427
546
  advantage = pd.Series(np.nan, index=profit.index)
@@ -444,17 +563,22 @@ def random_assignment_comparison(
444
563
  "expected_labor_cost": float(expected_labor.sum()),
445
564
  "expected_purchase_cost": float(expected_purchase.sum()),
446
565
  "expected_carcasses_per_pattern": total_carcasses / len(patterns),
447
- "advantage_over_random": None if not solved else float(advantage.sum()),
566
+ "advantage_over_random": None if not solved or not comparable else float(advantage.sum()),
567
+ **({"respects_eligibility": False} if not comparable else {}),
448
568
  }
569
+ if any(pattern.packaging_cost_per_head for pattern in inputs.patterns):
570
+ mean_packaging = sum(float(pattern.packaging_cost_per_head) / len(patterns)
571
+ for pattern in inputs.patterns)
572
+ report["expected_packaging_cost"] = float(mean_packaging * total_carcasses)
449
573
  return report, by_carcass
450
574
 
451
575
 
452
- def solve(inputs: CuttingPlanInputs) -> PlanResult:
576
+ def solve(inputs: CuttingPlanInputs, *, options: Mapping[str, float] | None = None) -> PlanResult:
453
577
  """Assign carcasses to patterns: a mixed-integer problem (one pattern per carcass, pattern
454
578
  capacities), solved exactly by HiGHS."""
455
579
  from beefcot.cutting_plan.solver import solve_assignment
456
580
 
457
- return solve_assignment(inputs)
581
+ return solve_assignment(inputs, options=options)
458
582
 
459
583
 
460
584
  def unusual_inputs(carcasses: pd.DataFrame, warnings) -> dict[str, dict[str, Any]]:
@@ -0,0 +1,158 @@
1
+ """Pattern eligibility evaluated against source rows and frozen model yields."""
2
+ from dataclasses import dataclass
3
+ import io
4
+ import json
5
+ import math
6
+ from typing import Mapping
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Rule:
14
+ kind: str
15
+ name: str
16
+ minimum: float | None = None
17
+ maximum: float | None = None
18
+ values: tuple[str, ...] = ()
19
+ basis: str | None = None
20
+
21
+ def to_dict(self):
22
+ if self.kind == 'category':
23
+ return {'kind': self.kind, 'column': self.name, 'values': list(self.values)}
24
+ target = {'product': self.name, 'basis': self.basis} if self.kind == 'product' else {'column': self.name}
25
+ return {'kind': self.kind, **target, 'min': self.minimum, 'max': self.maximum}
26
+
27
+ def label(self):
28
+ if self.kind == 'category':
29
+ return f'{self.name} in {json.dumps(self.values, ensure_ascii=False)}'
30
+ target = f'{self.name} ({self.basis})' if self.basis else self.name
31
+ low = '−∞' if self.minimum is None else f'{self.minimum:g}'
32
+ high = '+∞' if self.maximum is None else f'{self.maximum:g}'
33
+ return f'{target} in [{low}, {high}]'
34
+
35
+
36
+ def parse_rules(raw, context):
37
+ if not isinstance(raw, (list, tuple)):
38
+ raise ValueError(f'{context}: expected a list of rules')
39
+ rules = []
40
+ for index, item in enumerate(raw, 1):
41
+ where = f'{context}, rule {index}'
42
+ if not isinstance(item, Mapping):
43
+ raise ValueError(f'{where}: expected a mapping')
44
+ kind = item.get('kind')
45
+ if kind not in ('number', 'category', 'product'):
46
+ raise ValueError(f'{where}: choose number, category or product')
47
+ target = 'product' if kind == 'product' else 'column'
48
+ name = item.get(target)
49
+ if not isinstance(name, str) or not name.strip():
50
+ raise ValueError(f'{where}: choose a {target}')
51
+ allowed = {'kind', target, 'values'} if kind == 'category' else {'kind', target, 'min', 'max'}
52
+ if kind == 'product':
53
+ allowed.add('basis')
54
+ if set(item) - allowed:
55
+ raise ValueError(f'{where}: unexpected fields: {sorted(map(str, set(item) - allowed))}')
56
+ if kind == 'category':
57
+ values = item.get('values')
58
+ if not isinstance(values, list) or not values or any(not isinstance(v, str) or not v.strip() for v in values):
59
+ raise ValueError(f'{where}: values must be a nonempty list of category strings')
60
+ rules.append(Rule(kind, name, values=tuple(values)))
61
+ continue
62
+ bounds = []
63
+ for key in ('min', 'max'):
64
+ value = item.get(key)
65
+ if value is not None:
66
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
67
+ raise ValueError(f'{where}: {key} must be a finite number')
68
+ try:
69
+ value = float(value)
70
+ except OverflowError:
71
+ value = math.inf
72
+ if not math.isfinite(value):
73
+ raise ValueError(f'{where}: {key} must be a finite number')
74
+ if kind == 'product' and value < 0:
75
+ raise ValueError(f'{where}: product weight bounds must be nonnegative')
76
+ bounds.append(value)
77
+ low, high = bounds
78
+ if low is None and high is None:
79
+ raise ValueError(f'{where}: enter at least one bound')
80
+ if low is not None and high is not None and low > high:
81
+ raise ValueError(f'{where}: minimum exceeds maximum')
82
+ basis = item.get('basis')
83
+ if kind == 'product' and basis not in ('kg/side', 'kg/carcass'):
84
+ raise ValueError(f'{where}: choose kg/side or kg/carcass')
85
+ rules.append(Rule(kind, name, low, high, basis=basis))
86
+ return tuple(rules)
87
+
88
+
89
+ def source_values(raw, count):
90
+ """Read exact CSV category spellings without changing the model input table."""
91
+ stream = io.BytesIO(raw) if isinstance(raw, bytes) else raw
92
+ return pd.read_csv(stream, dtype=str, keep_default_na=False, nrows=count)
93
+
94
+
95
+ @dataclass
96
+ class Eligibility:
97
+ allowed: pd.DataFrame
98
+ rejected: dict[tuple[int, str], list[str]]
99
+
100
+ def table(self, ids, available):
101
+ return pd.DataFrame([
102
+ {'carcass': i, 'animal_id': ids[i], 'pattern': pattern,
103
+ 'available': int(available[i]), 'eligible': bool(self.allowed.iloc[i, p]),
104
+ 'reasons': json.dumps(self.rejected.get((i, pattern), []), ensure_ascii=False)}
105
+ for i in range(len(ids)) for p, pattern in enumerate(self.allowed.columns)
106
+ ])
107
+
108
+ def diagnostics(self, available, patterns, ids):
109
+ unmatched = [
110
+ {'carcass': i, 'animal_id': str(ids[i]), 'head': int(available[i]),
111
+ 'reasons': [f'{p}: {reason}' for p in self.allowed.columns
112
+ for reason in self.rejected.get((i, p), [])]}
113
+ for i in np.flatnonzero((available > 0) & ~self.allowed.to_numpy().any(axis=1)).tolist()
114
+ ]
115
+ counts = [
116
+ {'pattern': pattern.name, 'eligible_head': int(available[self.allowed[pattern.name].to_numpy()].sum()),
117
+ 'min_head': pattern.min_carcasses, 'max_head': pattern.max_carcasses}
118
+ for pattern in patterns
119
+ ]
120
+ return {'unmatched_rows': len(unmatched), 'unmatched_head': sum(row['head'] for row in unmatched),
121
+ 'unmatched_examples': unmatched[:10], 'patterns': counts}
122
+
123
+
124
+ def evaluate(scenario, source, yields, coverage):
125
+ if not any(pattern.eligibility for pattern in scenario.patterns):
126
+ return None
127
+ allowed = pd.DataFrame(True, index=range(len(source)), columns=[pattern.name for pattern in scenario.patterns])
128
+ rejected = {}
129
+ for pattern in scenario.patterns:
130
+ for rule in pattern.eligibility:
131
+ context = f'pattern {pattern.name!r}, eligibility {rule.label()}'
132
+ if rule.kind == 'product':
133
+ if rule.name not in yields or coverage.get(rule.name) in (None, 'zero', 'external'):
134
+ raise ValueError(f'{context}: product needs a frozen model prediction')
135
+ original = scenario.units.get('yield', 'kg/side')
136
+ factor = 1 if original == rule.basis else 2 if rule.basis == 'kg/carcass' else 0.5
137
+ values = yields[rule.name].reset_index(drop=True) * factor
138
+ else:
139
+ if rule.name not in source:
140
+ raise ValueError(f'{context}: source column is missing')
141
+ values = source[rule.name].reset_index(drop=True)
142
+ missing = values.isna() | values.astype(str).str.strip().eq('')
143
+ if rule.kind == 'category':
144
+ valid = values.astype(str).isin(rule.values) & ~missing
145
+ failure = 'category is not allowed'
146
+ else:
147
+ numbers = pd.to_numeric(values, errors='coerce').to_numpy(dtype=float)
148
+ valid = np.isfinite(numbers) & ~missing.to_numpy()
149
+ if rule.minimum is not None:
150
+ valid &= numbers >= rule.minimum
151
+ if rule.maximum is not None:
152
+ valid &= numbers <= rule.maximum
153
+ failure = 'value is outside the range or is not a finite number'
154
+ for row in np.flatnonzero(~np.asarray(valid)).tolist():
155
+ reason = 'value is missing' if missing.iloc[row] else f'{values.iloc[row]!r}: {failure}'
156
+ rejected.setdefault((row, pattern.name), []).append(f'{rule.label()}: {reason}')
157
+ allowed.at[row, pattern.name] = False
158
+ return Eligibility(allowed, rejected)