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.
- truereward-0.1.0/LICENSE +202 -0
- truereward-0.1.0/MANIFEST.in +3 -0
- truereward-0.1.0/PKG-INFO +51 -0
- truereward-0.1.0/README.md +37 -0
- truereward-0.1.0/pyproject.toml +27 -0
- truereward-0.1.0/setup.cfg +4 -0
- truereward-0.1.0/truereward/__init__.py +39 -0
- truereward-0.1.0/truereward/codes.py +152 -0
- truereward-0.1.0/truereward/document.py +152 -0
- truereward-0.1.0/truereward/fold.py +86 -0
- truereward-0.1.0/truereward/plugin.py +134 -0
- truereward-0.1.0/truereward/result.py +119 -0
- truereward-0.1.0/truereward/session.py +98 -0
- truereward-0.1.0/truereward.egg-info/PKG-INFO +51 -0
- truereward-0.1.0/truereward.egg-info/SOURCES.txt +16 -0
- truereward-0.1.0/truereward.egg-info/dependency_links.txt +1 -0
- truereward-0.1.0/truereward.egg-info/requires.txt +4 -0
- truereward-0.1.0/truereward.egg-info/top_level.txt +1 -0
truereward-0.1.0/LICENSE
ADDED
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
truereward
|