truereward 0.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.
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,3 @@
1
+ # The tests stay in the source tree: they spell the names the package must
2
+ # not carry in order to refuse them, and those names are not published.
3
+ prune tests
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: truereward
3
+ Version: 0.1.0
4
+ Summary: One case result, one fold, one grade document: the reward of a graded run, or the reason there is none.
5
+ Author: truereward contributors
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: pytest
11
+ Requires-Dist: pytest>=8; extra == "pytest"
12
+ Requires-Dist: pytest-xdist>=3.6; extra == "pytest"
13
+ Dynamic: license-file
14
+
15
+ # truereward
16
+
17
+ Turn the cases of a graded run into one reward, or into the reason there is
18
+ none.
19
+
20
+ A grader looks at some kind of evidence: a recorded end state, a test report,
21
+ a list of criteria, a measured figure. Whatever it looked at, it answers with
22
+ one record per case, `CaseResult`. This package is everything after that
23
+ record, and it is the same for every kind of evidence.
24
+
25
+ - **The fold.** `fold(rows)` applies `gated-binary/1`: the reward is 1.0 when
26
+ every pass-to-pass case holds and every fail-to-pass case passes, and 0.0
27
+ otherwise. `fix_rate` and `guards_hold` are reported beside it.
28
+ - **Three outcomes.** A case passed, failed, or could not be graded. The third
29
+ carries a code from a closed list, each with who failed: the run, or the
30
+ grader. An ungradable run has no reward. It is never scored 0.
31
+ - **The document.** `grade_document` builds `grade.json`; `write` publishes it
32
+ with `ctrf.json`, `reward.json` and `reward.txt` into a verifier's logs
33
+ directory, each file whole or not at all. An ungradable run publishes no
34
+ reward file.
35
+ - **The pytest plugin.** One test per case, folded once at the end of the
36
+ session, in one process or under pytest-xdist:
37
+
38
+ ```python
39
+ @pytest.mark.parametrize("case", cases(CASES), ids=lambda c: c.id)
40
+ def test_case(case, grade):
41
+ grade(case, grade_case)
42
+ ```
43
+
44
+ Run it with `pytest -p truereward.plugin`. Install the `pytest` extra for
45
+ it: `pip install "truereward[pytest]"`. Folding and writing need the
46
+ standard library alone.
47
+
48
+ A reader for one kind of evidence is a separate distribution. It may declare
49
+ refusal codes of its own with `truereward.codes.declare`.
50
+
51
+ Licensed under Apache-2.0.
@@ -0,0 +1,37 @@
1
+ # truereward
2
+
3
+ Turn the cases of a graded run into one reward, or into the reason there is
4
+ none.
5
+
6
+ A grader looks at some kind of evidence: a recorded end state, a test report,
7
+ a list of criteria, a measured figure. Whatever it looked at, it answers with
8
+ one record per case, `CaseResult`. This package is everything after that
9
+ record, and it is the same for every kind of evidence.
10
+
11
+ - **The fold.** `fold(rows)` applies `gated-binary/1`: the reward is 1.0 when
12
+ every pass-to-pass case holds and every fail-to-pass case passes, and 0.0
13
+ otherwise. `fix_rate` and `guards_hold` are reported beside it.
14
+ - **Three outcomes.** A case passed, failed, or could not be graded. The third
15
+ carries a code from a closed list, each with who failed: the run, or the
16
+ grader. An ungradable run has no reward. It is never scored 0.
17
+ - **The document.** `grade_document` builds `grade.json`; `write` publishes it
18
+ with `ctrf.json`, `reward.json` and `reward.txt` into a verifier's logs
19
+ directory, each file whole or not at all. An ungradable run publishes no
20
+ reward file.
21
+ - **The pytest plugin.** One test per case, folded once at the end of the
22
+ session, in one process or under pytest-xdist:
23
+
24
+ ```python
25
+ @pytest.mark.parametrize("case", cases(CASES), ids=lambda c: c.id)
26
+ def test_case(case, grade):
27
+ grade(case, grade_case)
28
+ ```
29
+
30
+ Run it with `pytest -p truereward.plugin`. Install the `pytest` extra for
31
+ it: `pip install "truereward[pytest]"`. Folding and writing need the
32
+ standard library alone.
33
+
34
+ A reader for one kind of evidence is a separate distribution. It may declare
35
+ refusal codes of its own with `truereward.codes.declare`.
36
+
37
+ Licensed under Apache-2.0.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "truereward"
7
+ dynamic = ["version"]
8
+ description = "One case result, one fold, one grade document: the reward of a graded run, or the reason there is none."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "Apache-2.0"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "truereward contributors"}]
14
+ dependencies = []
15
+
16
+ [project.optional-dependencies]
17
+ # What plugin.py needs. Folding and writing need the standard library alone.
18
+ pytest = ["pytest>=8", "pytest-xdist>=3.6"]
19
+
20
+ [tool.setuptools]
21
+ packages = ["truereward"]
22
+
23
+ [tool.setuptools.dynamic]
24
+ version = {attr = "truereward.__version__"}
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ """Grading: one case result, one fold, one document, one writer.
2
+
3
+ An environment answers what a run left behind and what it reported. Turning
4
+ those answers into a reward is this package, and it is the same for every
5
+ environment: a reader reduces its kind of evidence to a CaseResult per case
6
+ (result.py), the fold turns the results and the run checks into one reward or
7
+ a refusal with a code (fold.py, codes.py), and the writer publishes the grade
8
+ document and the files a Harbor verifier is read through (document.py). The
9
+ pytest plugin (plugin.py, over session.py) is how a task's tests reach them:
10
+ one test per case, folded once, in parallel or not.
11
+
12
+ Nothing here imports an environment, and nothing beyond the standard library
13
+ is needed to fold and write. pytest is imported by plugin.py alone.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ #: The version of this distribution, and the one place it is written down. It
19
+ #: stands above the imports below because document.py reads it.
20
+ __version__ = "0.1.0"
21
+
22
+ from .codes import CODES, MEANING, SIDE, Ungradable
23
+ from .document import GRADER, LOGS, ctrf, grade_document, logs, write
24
+ from .fold import SCHEME, fold
25
+ from .result import (
26
+ FAIL,
27
+ FAIL_TO_PASS,
28
+ KINDS,
29
+ PASS,
30
+ PASS_TO_PASS,
31
+ UNGRADABLE,
32
+ VERDICTS,
33
+ CaseResult,
34
+ )
35
+
36
+ __all__ = ["CODES", "MEANING", "SIDE", "Ungradable", "GRADER", "LOGS", "ctrf",
37
+ "grade_document", "logs", "write", "SCHEME", "fold", "FAIL",
38
+ "FAIL_TO_PASS", "KINDS", "PASS", "PASS_TO_PASS", "UNGRADABLE",
39
+ "VERDICTS", "CaseResult"]
@@ -0,0 +1,152 @@
1
+ """Why a grade could not be given: a closed set of codes, each with a sentence.
2
+
3
+ A grade has three outcomes and not two. A case passed, a case failed, or
4
+ nothing was measured at all. The third is not a failure of the candidate and
5
+ is never folded into one: SWE-bench once scored a suite that never ran as
6
+ resolved, and Inspect AI types the same distinction as a reason on an unscored
7
+ result rather than leaving it as prose.
8
+
9
+ The reason is a CODE from this list and the sentence beside it is for a
10
+ person. A consumer keys on ``code`` and never reads ``why``. Each code also
11
+ says who failed (``SIDE``): ``run`` when the candidate's run could not be
12
+ measured, ``grader`` when the grader itself could not do its work. A reader of
13
+ many runs counts the two apart, because the first is a fact about the
14
+ submission and the second is a defect to fix on this side.
15
+
16
+ Adding a code is a change to the contract, so the list is short on purpose and
17
+ every entry names a distinct thing a reader can act on. A code that only one
18
+ kind of evidence can raise belongs with the reader of that evidence and is not
19
+ listed here: the reader declares it (``declare``), as static data when it is
20
+ imported, with its sentence and its side. A code nobody declared is refused
21
+ wherever one is written down.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ #: Who failed when a grade could not be given.
27
+ RUN = "run"
28
+ GRADER = "grader"
29
+ SIDES = (RUN, GRADER)
30
+
31
+ #: Every reason a case, or a whole run, can be ungradable. Closed.
32
+ CODES = (
33
+ "CASE_INVALID",
34
+ "CASE_NOT_PREPARED",
35
+ "CASE_NOT_GRADED",
36
+ "NOTHING_GRADED",
37
+ "NO_FAIL_TO_PASS",
38
+ "RUN_NOT_DETERMINISTIC",
39
+ )
40
+
41
+ #: What each code means, in one sentence, for whoever has to fix it. Nothing
42
+ #: parses these.
43
+ MEANING = {
44
+ "CASE_INVALID":
45
+ "the case declaration does not satisfy the case schema, it names a "
46
+ "file the case does not ship, or two cases carry one id",
47
+ "CASE_NOT_PREPARED":
48
+ "the case could not be prepared for the run, so nothing was measured "
49
+ "for it and this run covers less than the case set holds",
50
+ "CASE_NOT_GRADED":
51
+ "the test of a case ended without a result: the reader raised "
52
+ "something other than a refusal, returned something other than a "
53
+ "result, or the process running it died, so nothing was measured for "
54
+ "that case",
55
+ "NOTHING_GRADED":
56
+ "no case of the case set was graded, or a case's evidence was "
57
+ "narrowed until it read nothing, and a case that reads nothing passes "
58
+ "forever while asserting nothing",
59
+ "NO_FAIL_TO_PASS":
60
+ "the case set classifies no fail-to-pass case, so nothing in it tests "
61
+ "the change that was asked for",
62
+ "RUN_NOT_DETERMINISTIC":
63
+ "the same submission run twice from the same start gave two different "
64
+ "results, so neither run is evidence about the submission",
65
+ }
66
+
67
+ #: Who failed, per code. The candidate's run could not be measured, or the
68
+ #: grader could not do its work.
69
+ SIDE = {
70
+ "CASE_INVALID": GRADER,
71
+ "CASE_NOT_PREPARED": GRADER,
72
+ "CASE_NOT_GRADED": GRADER,
73
+ "NOTHING_GRADED": GRADER,
74
+ "NO_FAIL_TO_PASS": GRADER,
75
+ "RUN_NOT_DETERMINISTIC": RUN,
76
+ }
77
+
78
+
79
+ #: The questions asked of the run rather than of a case, and the code each one
80
+ #: answers with when it does not hold. A reader answers a check per case; the
81
+ #: run holds it only when every case that answered it does.
82
+ CHECKS = {"determinism": "RUN_NOT_DETERMINISTIC"}
83
+
84
+ #: The codes a reader declared for its own kind of evidence, each
85
+ #: ``{"meaning", "side"}``. Filled by ``declare`` and by nothing else.
86
+ DECLARED: dict = {}
87
+
88
+
89
+ def declare(codes: dict) -> None:
90
+ """Take a reader's own codes in: ``{code: {"meaning": the sentence,
91
+ "side": who failed}}``. A reader calls this once, where it is imported,
92
+ over a literal. Declaring the same code the same way again changes
93
+ nothing, so a module imported twice is no defect; a code of the list
94
+ above, a second meaning for a declared code, a missing sentence and a side
95
+ that is neither of the two are each refused."""
96
+ for code, said in codes.items():
97
+ if code in CODES:
98
+ raise ValueError(f"{code!r} is a code of the shared list, and a "
99
+ f"reader declares only its own")
100
+ if set(said) != {"meaning", "side"} or not said["meaning"] \
101
+ or said["side"] not in SIDES:
102
+ raise ValueError(f"{code!r} is declared with a sentence and a "
103
+ f"side of {', '.join(SIDES)}, got {said!r}")
104
+ if DECLARED.get(code, said) != said:
105
+ raise ValueError(f"{code!r} is already declared, and differently")
106
+ DECLARED.update({code: dict(said) for code, said in codes.items()})
107
+
108
+
109
+ def known(code: str) -> bool:
110
+ """Whether ``code`` is a grading code: one of the shared list, or one a
111
+ reader declared."""
112
+ return code in CODES or code in DECLARED
113
+
114
+
115
+ def _refuse_unknown(code: str) -> None:
116
+ if not known(code):
117
+ raise ValueError(f"{code!r} is no grading code; the set is "
118
+ f"{', '.join(CODES + tuple(sorted(DECLARED)))}")
119
+
120
+
121
+ def entry(code: str, why: str, case: str | None = None) -> dict:
122
+ """One refusal as the grade document carries it: the code, the sentence,
123
+ who failed, and the case where the refusal was about one."""
124
+ _refuse_unknown(code)
125
+ out = {"code": code, "why": why,
126
+ "side": SIDE.get(code) or DECLARED[code]["side"]}
127
+ if case:
128
+ out["case"] = case
129
+ return out
130
+
131
+
132
+ class Ungradable(Exception):
133
+ """Raised where a grade cannot be given, carrying the code a consumer keys
134
+ on. Every raise site passes a ``why`` that names the case, the file or the
135
+ field, because the code says what kind of thing went wrong and the sentence
136
+ says which one."""
137
+
138
+ def __init__(self, code: str, why: str):
139
+ _refuse_unknown(code)
140
+ super().__init__(why)
141
+ self.code = code
142
+ self.why = why
143
+
144
+ def __reduce__(self):
145
+ """Both arguments, so a refusal raised in another process arrives as
146
+ the refusal it was. The default carries ``args`` alone, which is the
147
+ sentence and no code."""
148
+ return (type(self), (self.code, self.why))
149
+
150
+ def entry(self, case: str | None = None) -> dict:
151
+ """The refusal as the grade document carries it."""
152
+ return entry(self.code, self.why, case)
@@ -0,0 +1,152 @@
1
+ """The grade document, and the one writer of the files a Harbor verifier
2
+ publishes.
3
+
4
+ Harbor's rule is that the task produces its own reward: the verifier writes
5
+ ``reward.txt`` (one number) and ``reward.json`` (flat and numeric) into its
6
+ logs directory and Harbor reads them. Beside them go ``grade.json``, the whole
7
+ document, which is what every consumer on this side reads, and ``ctrf.json``,
8
+ the per-case detail in CTRF for a person to open.
9
+
10
+ An ungradable run publishes no reward file. Harbor then records a typed
11
+ missing-reward error for the trial, which it never retries and never scores,
12
+ where a written 0 would read as a fair grade of the submission. The document
13
+ and the CTRF report are written all the same, so the run always leaves a
14
+ well-formed record of why it could not be graded.
15
+
16
+ Every file is written to a temporary name in the same directory and renamed
17
+ into place, so a reader never opens half a document. ``reward.txt`` goes last:
18
+ once it is there, everything beside it is complete.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import tempfile
26
+
27
+ from . import __version__
28
+ from .fold import fold
29
+ from .result import FAIL, PASS
30
+
31
+ #: Where Harbor reads a task's verdict from.
32
+ LOGS = "/logs/verifier"
33
+
34
+ #: The environment variable that names another logs directory.
35
+ LOGS_ENV = "GRADE_LOGS"
36
+
37
+ #: Who folded and wrote, as the document and the CTRF report name it: this
38
+ #: distribution and its version.
39
+ GRADER = {"name": "truereward", "version": __version__}
40
+
41
+ #: The version of the document this module writes.
42
+ SCHEMA_VERSION = 2
43
+
44
+ #: The document, field for field. The list is the contract: a reader of it
45
+ #: knows what arrives, and a key added to the fold reaches the document only by
46
+ #: being named here.
47
+ DOCUMENT_KEYS = ("grade", "schema_version", "grader", "gradable", "ungradable",
48
+ "reward", "reward_scheme", "guards_hold", "fix_rate",
49
+ "case_fraction", "cases", "checks", "detail")
50
+
51
+ #: The files a gradable run publishes, in the order they are written, and the
52
+ #: two of them an ungradable run must not leave behind.
53
+ FILES = ("grade.json", "ctrf.json", "reward.json", "reward.txt")
54
+ REWARD_FILES = ("reward.json", "reward.txt")
55
+
56
+
57
+ def logs() -> str:
58
+ """The logs directory of this run: Harbor's, or the one ``GRADE_LOGS``
59
+ names. The writer publishes into it, and a reader that asks its
60
+ environment to keep the candidate's code out of the grader's files names
61
+ it among them."""
62
+ return os.environ.get(LOGS_ENV) or LOGS
63
+
64
+
65
+ def grade_document(rows: list, checks: list | tuple = (),
66
+ refusals: list | tuple = ()) -> dict:
67
+ """The grade document over one run.
68
+
69
+ ``rows`` is one CaseResult row per case, ungradable cases included.
70
+ ``checks`` is the run checks, each ``{"check", "code", "holds",
71
+ "answered", "why"}``. ``refusals`` is every refusal about the run that no
72
+ row carries. ``detail`` is the rows in id order, so two runs of one case
73
+ set give one document whatever order the cases finished in.
74
+ """
75
+ folded = fold(rows, checks, refusals)
76
+ doc = {
77
+ "grade": SCHEMA_VERSION,
78
+ "schema_version": SCHEMA_VERSION,
79
+ "grader": dict(GRADER),
80
+ "checks": [dict(c) for c in checks],
81
+ "detail": sorted((dict(r) for r in rows), key=lambda r: r["id"]),
82
+ **folded,
83
+ }
84
+ return {k: doc[k] for k in DOCUMENT_KEYS}
85
+
86
+
87
+ def ctrf(doc: dict) -> dict:
88
+ """The per-case report in CTRF: a tool, a summary and one entry per case.
89
+ A case that could not be graded is neither passed nor failed, and CTRF has
90
+ the word for it, ``other``. A refusal about the run that no case carries
91
+ is one more ``other`` entry, named by its code. Each entry says what the
92
+ case is for and the code it was refused under in ``extra``."""
93
+ status = {PASS: "passed", FAIL: "failed"}
94
+ tests = [{"name": row["id"],
95
+ "status": status.get(row["verdict"], "other"),
96
+ "duration": int(row["duration_s"] * 1000),
97
+ "message": row["why"], "suite": row["kind"],
98
+ "extra": {"kind": row["kind"], "code": row["code"]}}
99
+ for row in doc["detail"]]
100
+ tests += [{"name": refusal["code"], "status": "other", "duration": 0,
101
+ "message": refusal["why"],
102
+ "extra": {"kind": "", "code": refusal["code"]}}
103
+ for refusal in doc["ungradable"] if "case" not in refusal]
104
+ count = [t["status"] for t in tests].count
105
+ return {"results": {
106
+ "tool": dict(doc["grader"]),
107
+ "summary": {"tests": len(tests), "passed": count("passed"),
108
+ "failed": count("failed"), "pending": 0, "skipped": 0,
109
+ "other": count("other"), "start": 0, "stop": 0},
110
+ "tests": tests}}
111
+
112
+
113
+ def _write(logs: str, name: str, text: str) -> None:
114
+ """One file, whole or not at all: a temporary name beside it, then a
115
+ rename, which the file system does in one step."""
116
+ fd, tmp = tempfile.mkstemp(dir=logs, prefix=f".{name}.")
117
+ try:
118
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
119
+ fh.write(text)
120
+ os.replace(tmp, os.path.join(logs, name))
121
+ except BaseException:
122
+ os.unlink(tmp)
123
+ raise
124
+
125
+
126
+ def write(doc: dict, logs: str = LOGS) -> list:
127
+ """Publish one run into ``logs`` and return the names written.
128
+
129
+ A gradable run writes all four files. An ungradable run writes the
130
+ document and the CTRF report, writes no reward file, and removes one a
131
+ previous run left, so a stale reward never stands beside a document that
132
+ says there is none. ``reward.json`` is flat and numeric, ``guards_hold`` as
133
+ 1 or 0, because Harbor refuses anything else in it.
134
+ """
135
+ os.makedirs(logs, exist_ok=True)
136
+ texts = {
137
+ "grade.json": json.dumps(doc, indent=1, sort_keys=True) + "\n",
138
+ "ctrf.json": json.dumps(ctrf(doc), indent=1) + "\n",
139
+ }
140
+ if doc["gradable"]:
141
+ texts["reward.json"] = json.dumps(
142
+ {"reward": doc["reward"], "fix_rate": doc["fix_rate"],
143
+ "guards_hold": int(doc["guards_hold"])}, indent=1) + "\n"
144
+ texts["reward.txt"] = f"{doc['reward']}\n"
145
+ else:
146
+ for name in REWARD_FILES:
147
+ if os.path.exists(os.path.join(logs, name)):
148
+ os.unlink(os.path.join(logs, name))
149
+ written = [name for name in FILES if name in texts]
150
+ for name in written:
151
+ _write(logs, name, texts[name])
152
+ return written
@@ -0,0 +1,86 @@
1
+ """The fold: many case results and a few run checks, one reward.
2
+
3
+ A case set is the behaviour the change must produce (the fail-to-pass cases)
4
+ and the behaviour it must not break (the pass-to-pass cases), which is
5
+ SWE-bench's F2P and P2P. Under ``gated-binary/1`` the reward is 1.0 when every
6
+ pass-to-pass case holds and every fail-to-pass case passes, and 0.0 otherwise.
7
+ Nothing in between: an untouched codebase scores 0 because its fail-to-pass
8
+ cases fail, and a fix that breaks a guard scores 0 because the guard is the
9
+ contract. ``fix_rate`` and ``guards_hold`` are reported beside the reward for
10
+ whoever wants a dense signal, and are never the grade.
11
+
12
+ A case that could not be graded is never counted as a failed one. It is left
13
+ out of the arithmetic and makes the whole run ungradable, as does a run check
14
+ that does not hold, a run that graded no case, and a case set with no
15
+ fail-to-pass case. An ungradable run has no reward: ``reward`` is None, never
16
+ 0, so an instrument failure cannot read as a fair grade. The checks and the
17
+ cases are read in this one function, so a run that failed a check cannot
18
+ publish 1.0 from its case rows.
19
+
20
+ This is the only implementation of the rule. A consumer that has to recompute
21
+ a reward from the rows of a document calls this fold.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .codes import entry
27
+ from .result import FAIL_TO_PASS, PASS, PASS_TO_PASS, UNGRADABLE
28
+
29
+ #: How this reward is named wherever it is published.
30
+ SCHEME = "gated-binary/1"
31
+
32
+
33
+ def fold(rows: list, checks: list | tuple = (),
34
+ refusals: list | tuple = ()) -> dict:
35
+ """``rows`` is one CaseResult row per case, of which ``id``, ``kind``,
36
+ ``verdict``, ``code`` and ``why`` are read. ``checks`` is the run checks,
37
+ each ``{"check", "code", "holds", "why"}``. ``refusals`` is every refusal
38
+ about the run that no row carries, each as codes.entry builds it.
39
+
40
+ Returns ``gradable``, the full ``ungradable`` list, ``reward`` (None when
41
+ not gradable), ``reward_scheme``, ``guards_hold``, ``fix_rate``,
42
+ ``case_fraction``, the counts split by fail-to-pass and pass-to-pass, and
43
+ the ids that failed. The counts and the rates are over the cases that were
44
+ graded, whether or not the run is gradable.
45
+ """
46
+ graded = [r for r in rows if r["verdict"] != UNGRADABLE]
47
+ refused = [entry(r["code"], r["why"], r["id"])
48
+ for r in rows if r["verdict"] == UNGRADABLE]
49
+ refused += [entry(r["code"], r["why"], r.get("case")) for r in refusals]
50
+ refused += [entry(c["code"], c["why"]) for c in checks if not c["holds"]]
51
+ if not graded:
52
+ # Only when nothing has said why already: a run whose cases each
53
+ # refused has its reasons, and repeating the code over them adds no
54
+ # fact.
55
+ if not refused:
56
+ refused.append(entry("NOTHING_GRADED",
57
+ "no case of this case set was graded on "
58
+ "this run"))
59
+ elif not any(r["kind"] == FAIL_TO_PASS for r in rows):
60
+ refused.append(entry("NO_FAIL_TO_PASS",
61
+ "this case set classifies no fail-to-pass case, "
62
+ "so nothing in it tests the change that was "
63
+ "asked for"))
64
+
65
+ fix = [r for r in graded if r["kind"] == FAIL_TO_PASS]
66
+ guards = [r for r in graded if r["kind"] == PASS_TO_PASS]
67
+ fix_pass = sum(1 for r in fix if r["verdict"] == PASS)
68
+ guard_pass = sum(1 for r in guards if r["verdict"] == PASS)
69
+ guards_hold = guard_pass == len(guards)
70
+ total = len(graded)
71
+ gradable = not refused
72
+ reward = 1.0 if guards_hold and fix_pass == len(fix) else 0.0
73
+ fix_rate = (fix_pass / len(fix)) if (fix and guards_hold) else 0.0
74
+ return {
75
+ "gradable": gradable,
76
+ "ungradable": refused,
77
+ "reward": reward if gradable else None,
78
+ "reward_scheme": SCHEME,
79
+ "guards_hold": guards_hold,
80
+ "fix_rate": round(fix_rate, 4),
81
+ "case_fraction": round((fix_pass + guard_pass) / total, 4) if total else 0.0,
82
+ "cases": {"total": total, "passed": fix_pass + guard_pass,
83
+ "fail_to_pass": {"total": len(fix), "passed": fix_pass},
84
+ "pass_to_pass": {"total": len(guards), "passed": guard_pass}},
85
+ "failed": [r["id"] for r in graded if r["verdict"] != PASS],
86
+ }
@@ -0,0 +1,134 @@
1
+ """The pytest plugin: one test per case, one fold per run, in parallel or not.
2
+
3
+ A task's tests are one test per case, and a reward is one number over the
4
+ whole case set, so something has to fold the cases after the last one has run.
5
+ Under pytest-xdist the cases run in worker processes, and a ledger kept in
6
+ memory would give every worker its own partial reward. So nothing is kept in
7
+ the process that ran the case: the ``grade`` fixture attaches the case's row
8
+ to the test report (``user_properties``, the channel pytest and xdist already
9
+ carry between processes), and the controller, which is the only process that
10
+ sees every report, collects the rows and folds and writes once at the end of
11
+ the session. Without xdist the one process is its own controller and the path
12
+ is the same. A worker never writes a file.
13
+
14
+ The plugin is never active on its own: no entry point names it. A verifier
15
+ names it on the command line, ``pytest -p <this module>``, which is read by
16
+ the controller and by every worker. A ``pytest_plugins`` line in the task's
17
+ test file is not enough: under xdist the controller never imports the test
18
+ file, so it would collect no row and publish nothing. The task's test file
19
+ stays a few lines::
20
+
21
+ @pytest.mark.parametrize("case", cases(CASES), ids=lambda c: c.id)
22
+ def test_case(case, grade):
23
+ grade(case, grade_case)
24
+
25
+ where ``cases`` and ``grade_case(case) -> CaseResult`` are the reader's. The
26
+ logs directory is Harbor's, or the one ``GRADE_LOGS`` names.
27
+
28
+ A reader that declares codes of its own (codes.declare) is named on the
29
+ command line too, for the same reason: the controller reads every row, a row
30
+ is refused unless its code is declared in the process reading it, and the
31
+ controller never imports the test file that imports the reader. Such a reader
32
+ carries a ``pytest_plugins`` line naming this module, so the verifier names
33
+ the reader and gets both. A row whose code the controller does not know is a
34
+ refusal about that case, never a crash of the run.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import pytest
40
+
41
+ from .document import logs, write
42
+ from .result import PASS
43
+ from .session import Session, grade_one
44
+
45
+ #: The name a case's row travels under in a test report's user properties.
46
+ ROW = "grade_row"
47
+
48
+ #: The ways a session ends before its last test has run.
49
+ STOPPED_EARLY = (pytest.ExitCode.INTERRUPTED, pytest.ExitCode.INTERNAL_ERROR)
50
+
51
+
52
+ @pytest.fixture
53
+ def grade(request):
54
+ """``grade(case, reader)``: grade one case, attach its row to this test's
55
+ report, and fail the test unless the case passed. A refusal is attached
56
+ like any other row and then re-raised, so the case's own test fails and
57
+ the run is ungradable rather than quietly short."""
58
+ def run(case, reader) -> None:
59
+ result, raised = grade_one(case, reader)
60
+ request.node.user_properties.append((ROW, result.to_row()))
61
+ if raised is not None:
62
+ raise raised
63
+ assert result.verdict == PASS, result.why or result.code
64
+ return run
65
+
66
+
67
+ class Controller:
68
+ """The one process that sees every report. It is registered where there
69
+ is no ``workerinput``, which is how xdist marks a worker."""
70
+
71
+ def __init__(self, config):
72
+ self.config = config
73
+ self.session = Session()
74
+ self.collected: set = set()
75
+ self.seen: set = set()
76
+ self.ended_badly: dict = {}
77
+
78
+ def pytest_collection_finish(self, session) -> None:
79
+ """The case set of a run in one process: every test it collected."""
80
+ self.collected.update(item.nodeid for item in session.items)
81
+
82
+ @pytest.hookimpl(optionalhook=True)
83
+ def pytest_xdist_node_collection_finished(self, node, ids) -> None:
84
+ """The case set of a run under xdist, where the workers collect and
85
+ each tells the controller what it found."""
86
+ self.collected.update(ids)
87
+
88
+ def pytest_runtest_logreport(self, report) -> None:
89
+ """Take the row off a report. A test's set-up, call and tear-down
90
+ reports all carry its properties, so a test is read once. A test that
91
+ did not pass and carried no row is remembered, to be refused at the
92
+ end."""
93
+ for name, value in report.user_properties:
94
+ if name == ROW and report.nodeid not in self.seen:
95
+ self.seen.add(report.nodeid)
96
+ try:
97
+ self.session.add(value)
98
+ except ValueError as error:
99
+ self.session.lost(report.nodeid, f"its row was refused "
100
+ f"here: {error}")
101
+ if not report.passed:
102
+ self.ended_badly.setdefault(
103
+ report.nodeid, f"the test {report.outcome} in {report.when} "
104
+ f"and left no result")
105
+
106
+ def pytest_sessionfinish(self, session, exitstatus) -> None:
107
+ """Fold and write, once, after every case of this run has reported. A
108
+ run that was stopped before its last case (an interrupt, an internal
109
+ error, ``-x``) graded part of a case set, and says so."""
110
+ for nodeid, why in self.ended_badly.items():
111
+ if nodeid not in self.seen:
112
+ self.session.lost(nodeid, why)
113
+ for nodeid in sorted(self.collected - self.seen - set(self.ended_badly)):
114
+ self.session.lost(nodeid, "the test was collected and left no result")
115
+ chosen = [f"{flag} {value}" for flag, value in (
116
+ ("-k", self.config.getoption("keyword", "")),
117
+ ("-m", self.config.getoption("markexpr", "")),
118
+ ("--deselect", self.config.getoption("deselect", None))) if value]
119
+ if chosen:
120
+ self.session.lost(
121
+ "session", f"the run chose its cases ({', '.join(chosen)}), "
122
+ f"and a reward is over the whole case set")
123
+ stopped = session.shouldfail or session.shouldstop
124
+ if stopped or exitstatus in STOPPED_EARLY:
125
+ self.session.lost(
126
+ "session", f"the run stopped early ({stopped or exitstatus!r}), "
127
+ f"so cases of the set may never have run")
128
+ write(self.session.document(), logs())
129
+
130
+
131
+ def pytest_configure(config) -> None:
132
+ """Register the controller in the controlling process alone."""
133
+ if not hasattr(config, "workerinput"):
134
+ config.pluginmanager.register(Controller(config), "grade-controller")
@@ -0,0 +1,119 @@
1
+ """The one record every kind of evidence reduces to: the result of one case.
2
+
3
+ A case is graded by a reader that knows its kind of evidence: a recorded end
4
+ state, a test report, a list of criteria, a measured figure. Whatever the
5
+ reader looked at, it answers with this record, and everything after it (the
6
+ fold, the grade document, the files a verifier publishes) reads this record
7
+ and nothing else. A new kind of evidence is a new reader, never a new record.
8
+
9
+ ``to_row`` and ``from_row`` carry the record as a plain JSON dict, because a
10
+ row crosses a process boundary whenever the cases run in parallel.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from .codes import CHECKS, known
18
+
19
+ #: What a case is for. A fail-to-pass case is behaviour the change must
20
+ #: produce; a pass-to-pass case is behaviour it must not break.
21
+ FAIL_TO_PASS = "fail_to_pass"
22
+ PASS_TO_PASS = "pass_to_pass"
23
+ KINDS = (FAIL_TO_PASS, PASS_TO_PASS)
24
+
25
+ #: How a case came out. ``ungradable`` is neither of the other two: nothing
26
+ #: was measured, and the code says why.
27
+ PASS = "pass"
28
+ FAIL = "fail"
29
+ UNGRADABLE = "ungradable"
30
+ VERDICTS = (PASS, FAIL, UNGRADABLE)
31
+
32
+ #: One entry of ``observations``, field for field.
33
+ OBSERVATION_KEYS = ("name", "verdict", "why")
34
+
35
+ #: One entry of ``checks``, field for field: the run check this case answered,
36
+ #: whether it held, and the sentence when it did not.
37
+ CHECK_KEYS = ("check", "holds", "why")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CaseResult:
42
+ """One case, graded.
43
+
44
+ ``code`` is set exactly when the verdict is ``ungradable``, and is a code
45
+ of codes.CODES or one a reader declared. ``why`` is the sentence a person reads: the rule the case
46
+ failed on, or which thing could not be measured. ``observations`` is one
47
+ entry per thing the reader looked at, each ``{name, verdict, why}`` in the
48
+ reader's own vocabulary; ``measures`` is every figure it took, by name.
49
+ Neither enters the reward: the fold reads ``kind`` and ``verdict`` alone.
50
+ ``checks`` is this case's answer to each run check it could answer (one of
51
+ codes.CHECKS), each ``{check, holds, why}``. A check is a question about
52
+ the run and not about the case, so it moves no verdict here: the ledger
53
+ gathers the answers of every case and the fold reads them once.
54
+ ``containment`` is what the environment's report said about how this
55
+ case's run was kept from the grader's files, in the environment's own
56
+ words; None where the reader ran nothing or asked for none.
57
+ """
58
+
59
+ id: str
60
+ kind: str
61
+ verdict: str
62
+ code: str = ""
63
+ why: str = ""
64
+ observations: list = field(default_factory=list)
65
+ measures: dict = field(default_factory=dict)
66
+ checks: list = field(default_factory=list)
67
+ containment: dict | None = None
68
+ duration_s: float = 0.0
69
+
70
+ def __post_init__(self):
71
+ if not self.id or not isinstance(self.id, str):
72
+ raise ValueError(f"a case result needs an id, got {self.id!r}")
73
+ if self.kind not in KINDS:
74
+ raise ValueError(f"{self.id}: kind {self.kind!r} is not one of "
75
+ f"{', '.join(KINDS)}")
76
+ if self.verdict not in VERDICTS:
77
+ raise ValueError(f"{self.id}: verdict {self.verdict!r} is not one "
78
+ f"of {', '.join(VERDICTS)}")
79
+ if (self.verdict == UNGRADABLE) != bool(self.code):
80
+ raise ValueError(f"{self.id}: a code goes with the verdict "
81
+ f"{UNGRADABLE!r} and with no other")
82
+ if self.code and not known(self.code):
83
+ raise ValueError(f"{self.id}: {self.code!r} is no grading code")
84
+ for seen in self.observations:
85
+ if not isinstance(seen, dict) or set(seen) != set(OBSERVATION_KEYS):
86
+ raise ValueError(f"{self.id}: an observation is "
87
+ f"{{name, verdict, why}}, got {seen!r}")
88
+ for answer in self.checks:
89
+ if not isinstance(answer, dict) or set(answer) != set(CHECK_KEYS) \
90
+ or answer["check"] not in CHECKS \
91
+ or not isinstance(answer["holds"], bool):
92
+ raise ValueError(f"{self.id}: a check is {{check, holds, "
93
+ f"why}} over one of {', '.join(CHECKS)}, "
94
+ f"got {answer!r}")
95
+ if self.containment is not None \
96
+ and not isinstance(self.containment, dict):
97
+ raise ValueError(f"{self.id}: containment is what a report said, "
98
+ f"an object, got {self.containment!r}")
99
+ for name, figure in self.measures.items():
100
+ if isinstance(figure, bool) or not isinstance(figure, (int, float)):
101
+ raise ValueError(f"{self.id}: measure {name!r} is not a "
102
+ f"number: {figure!r}")
103
+
104
+ def to_row(self) -> dict:
105
+ """The record as a plain JSON dict."""
106
+ return {"id": self.id, "kind": self.kind, "verdict": self.verdict,
107
+ "code": self.code, "why": self.why,
108
+ "observations": [dict(seen) for seen in self.observations],
109
+ "measures": dict(self.measures),
110
+ "checks": [dict(answer) for answer in self.checks],
111
+ "containment": (None if self.containment is None
112
+ else dict(self.containment)),
113
+ "duration_s": round(float(self.duration_s), 3)}
114
+
115
+ @classmethod
116
+ def from_row(cls, row: dict) -> CaseResult:
117
+ """The record a row carries. A row with a key this record does not
118
+ have is refused, so a field never rides along unread."""
119
+ return cls(**row)
@@ -0,0 +1,98 @@
1
+ """One graded run: the cases as they come in, and the one document they fold
2
+ into.
3
+
4
+ The reward is folded once, over the whole case set, and a test of one case
5
+ cannot see the set. So each test grades its case into a row (``grade_one``),
6
+ and one ledger per run (``Session``) takes the rows in, in whatever order and
7
+ from whatever process they arrive, and builds the document at the end. Nothing
8
+ here knows pytest; plugin.py is the part that does.
9
+
10
+ A case that ends without a row is never dropped. The fold would otherwise run
11
+ over the cases that are left and could publish 1.0 for a case set it only
12
+ partly graded. A reader that raises, a reader that returns something else, and
13
+ a test that died before its row arrived each become a refusal.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import time
20
+
21
+ from .codes import CHECKS, Ungradable, entry
22
+ from .document import grade_document
23
+ from .result import UNGRADABLE, CaseResult
24
+
25
+
26
+ def grade_one(case, reader) -> tuple[CaseResult, BaseException | None]:
27
+ """Grade one case through ``reader`` and time it.
28
+
29
+ ``case`` is whatever the reader discovers its cases as; it carries ``id``
30
+ and ``kind``. ``reader(case)`` returns a CaseResult, or raises Ungradable
31
+ where the case cannot be graded. Returns the result and whatever the
32
+ reader raised, so the caller records the first and re-raises the second.
33
+ A reader that raised anything else, or answered with no result for this
34
+ case, leaves an ungradable result under CASE_NOT_GRADED.
35
+ """
36
+ started = time.monotonic()
37
+ raised = None
38
+ try:
39
+ result = reader(case)
40
+ if not isinstance(result, CaseResult) or result.id != case.id:
41
+ raise TypeError(f"the reader answered {result!r}, which is no "
42
+ f"result for case {case.id}")
43
+ except Ungradable as refusal:
44
+ raised = refusal
45
+ result = CaseResult(case.id, case.kind, UNGRADABLE, refusal.code,
46
+ refusal.why)
47
+ except Exception as error: # noqa: BLE001
48
+ raised = error
49
+ result = CaseResult(case.id, case.kind, UNGRADABLE, "CASE_NOT_GRADED",
50
+ f"{case.id}: {type(error).__name__}: {error}")
51
+ return dataclasses.replace(
52
+ result, duration_s=time.monotonic() - started), raised
53
+
54
+
55
+ class Session:
56
+ """The ledger of one run: every row that arrived, every refusal no row
57
+ carries, and the document they fold into."""
58
+
59
+ def __init__(self):
60
+ self.rows: dict = {}
61
+ self.refusals: list = []
62
+
63
+ def add(self, row: dict) -> None:
64
+ """Take one case's row in. A second row under one id is a defect of
65
+ the case set, and is refused rather than allowed to replace the
66
+ first."""
67
+ result = CaseResult.from_row(row)
68
+ if result.id in self.rows:
69
+ self.refusals.append(entry(
70
+ "CASE_INVALID", f"two cases carry the id {result.id}",
71
+ result.id))
72
+ return
73
+ self.rows[result.id] = result.to_row()
74
+
75
+ def lost(self, name: str, why: str) -> None:
76
+ """Record a test that ended badly with no row behind it."""
77
+ self.refusals.append(entry("CASE_NOT_GRADED", f"{name}: {why}", name))
78
+
79
+ def checks(self) -> list:
80
+ """Every run check, answered over the whole run: it holds when every
81
+ case that answered it says so. A check that no case answered is not a
82
+ failing check: the run that graded no case is refused by the fold on
83
+ its own terms, and borrowing this code for it would make the code mean
84
+ two things."""
85
+ out = []
86
+ for name, code in CHECKS.items():
87
+ answers = [answer for row in self.rows.values()
88
+ for answer in row["checks"] if answer["check"] == name]
89
+ failed = [answer for answer in answers if not answer["holds"]]
90
+ out.append({"check": name, "code": code, "holds": not failed,
91
+ "answered": len(answers),
92
+ "why": " ".join(answer["why"] for answer in failed)})
93
+ return out
94
+
95
+ def document(self) -> dict:
96
+ """The grade document over everything that arrived, folded once."""
97
+ return grade_document(list(self.rows.values()), self.checks(),
98
+ self.refusals)
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: truereward
3
+ Version: 0.1.0
4
+ Summary: One case result, one fold, one grade document: the reward of a graded run, or the reason there is none.
5
+ Author: truereward contributors
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: pytest
11
+ Requires-Dist: pytest>=8; extra == "pytest"
12
+ Requires-Dist: pytest-xdist>=3.6; extra == "pytest"
13
+ Dynamic: license-file
14
+
15
+ # truereward
16
+
17
+ Turn the cases of a graded run into one reward, or into the reason there is
18
+ none.
19
+
20
+ A grader looks at some kind of evidence: a recorded end state, a test report,
21
+ a list of criteria, a measured figure. Whatever it looked at, it answers with
22
+ one record per case, `CaseResult`. This package is everything after that
23
+ record, and it is the same for every kind of evidence.
24
+
25
+ - **The fold.** `fold(rows)` applies `gated-binary/1`: the reward is 1.0 when
26
+ every pass-to-pass case holds and every fail-to-pass case passes, and 0.0
27
+ otherwise. `fix_rate` and `guards_hold` are reported beside it.
28
+ - **Three outcomes.** A case passed, failed, or could not be graded. The third
29
+ carries a code from a closed list, each with who failed: the run, or the
30
+ grader. An ungradable run has no reward. It is never scored 0.
31
+ - **The document.** `grade_document` builds `grade.json`; `write` publishes it
32
+ with `ctrf.json`, `reward.json` and `reward.txt` into a verifier's logs
33
+ directory, each file whole or not at all. An ungradable run publishes no
34
+ reward file.
35
+ - **The pytest plugin.** One test per case, folded once at the end of the
36
+ session, in one process or under pytest-xdist:
37
+
38
+ ```python
39
+ @pytest.mark.parametrize("case", cases(CASES), ids=lambda c: c.id)
40
+ def test_case(case, grade):
41
+ grade(case, grade_case)
42
+ ```
43
+
44
+ Run it with `pytest -p truereward.plugin`. Install the `pytest` extra for
45
+ it: `pip install "truereward[pytest]"`. Folding and writing need the
46
+ standard library alone.
47
+
48
+ A reader for one kind of evidence is a separate distribution. It may declare
49
+ refusal codes of its own with `truereward.codes.declare`.
50
+
51
+ Licensed under Apache-2.0.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ truereward/__init__.py
6
+ truereward/codes.py
7
+ truereward/document.py
8
+ truereward/fold.py
9
+ truereward/plugin.py
10
+ truereward/result.py
11
+ truereward/session.py
12
+ truereward.egg-info/PKG-INFO
13
+ truereward.egg-info/SOURCES.txt
14
+ truereward.egg-info/dependency_links.txt
15
+ truereward.egg-info/requires.txt
16
+ truereward.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+
2
+ [pytest]
3
+ pytest>=8
4
+ pytest-xdist>=3.6
@@ -0,0 +1 @@
1
+ truereward