entrylib-rubric 0.4.0__py3-none-any.whl

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,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: entrylib-rubric
3
+ Version: 0.4.0
4
+ Summary: EntryLib's scoring rubric: reliability scores of claimsets, computed by public rules.
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/entrylib/rubric
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # EntryLib scoring rubric
13
+
14
+ How [EntryLib](https://entrylib.org) scores the claimsets it publishes: a **reliability** score for every claim and for the paper, and a **novelty** label with a close-match check. The rules are public so that anyone can see why a claimset scored as it did, and what would raise it.
15
+
16
+ - [RUBRIC.md](https://github.com/entrylib/rubric/blob/main/RUBRIC.md): the rubric, normative and versioned.
17
+ - `src/rubric/`: the scorer that implements it (Python 3.10+, standard library only); the costs are in `src/rubric/rubric.json`.
18
+
19
+ ```python
20
+ import json, claimset.validate, rubric
21
+ doc = json.load(open("claimset.json")) # a published claimset file
22
+ result = rubric.score(doc, warnings=claimset.validate.validate(doc, published=True).warnings) # warnings optional
23
+ result["paper"]["score"], result["claims"]["C1"]["dots"]
24
+ ```
25
+
26
+ `rubric.report(result)` gives the scores as plain text: every issue that lowers a score and what would fix it. Before submitting, `claimset score paper.claimset.json` ([claimset](https://github.com/entrylib/claimset)) scores a claimset this way on your own machine, as it would be scored once published.
27
+
28
+ Install: `pip install git+https://github.com/entrylib/rubric`. The portal installs it the same way and records the rubric version with every score.
29
+
30
+ Planned: an AI reviewer ([#1](https://github.com/entrylib/rubric/issues/1)) and reproduction from artefacts ([#2](https://github.com/entrylib/rubric/issues/2)).
@@ -0,0 +1,10 @@
1
+ entrylib_rubric-0.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
2
+ rubric/__init__.py,sha256=zzhQy_saVoRptJqv--h04Q7CU0pCfgKYTGx3qur_3Gs,1251
3
+ rubric/novelty.py,sha256=eCC9L8793VUPZLjTfLKnjv1_WRot1Wr_2HmuYWs_PvQ,4794
4
+ rubric/report.py,sha256=v_45iZZjKy5V-chsCmwNQKf1pg7idNMxRgYh8dhtUjs,4554
5
+ rubric/rubric.json,sha256=8ETVWMfJOniY5EcP7ZlToL12a7nIE2Oe_cEoUVOF0q4,5249
6
+ rubric/score.py,sha256=Or6SW2hA56bitgF9rPrcL57BxLZjlD988pkpc6EzFc4,19405
7
+ entrylib_rubric-0.4.0.dist-info/METADATA,sha256=e50xvBloOqGGU77aM9Kk30vWtbBcLPXQhHcFGZW_A_M,1817
8
+ entrylib_rubric-0.4.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ entrylib_rubric-0.4.0.dist-info/top_level.txt,sha256=PB2Y0f8oFtUr8uA8PkbDIgEHmZsqJvCBG1zeQtm_4EI,7
10
+ entrylib_rubric-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ rubric
rubric/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """EntryLib's scoring rubric (RUBRIC.md): reliability scores of a claimset's claims and of the paper, computed by rules
2
+ from the published claimset file, and the novelty close-match check.
3
+
4
+ score(doc, warnings=None) -> the scores of a claimset: claims, paper, problems (see score.py); warnings
5
+ are the claimset validator's for the file (its validation report)
6
+ assess(doc, find=None, warnings=None)
7
+ -> score() with novelty, comparing principal claims with what find() returns
8
+ novelty(claim, matches, ...) -> a claim's novelty label and close-match result (see novelty.py)
9
+ report(scores, biggest_n=0) -> the scores as a plain-text report (see report.py)
10
+ similarity(a, b) -> how closely two claim statements match, 0 to 1
11
+ stems(statement) -> the stems similarity compares
12
+ VERSION -> the rubric version every score records
13
+
14
+ Standard library only, Python 3.10+.
15
+ """
16
+
17
+ from .novelty import assess, novelty, similarity, stems
18
+ from .report import report
19
+ from .score import RUBRIC, VERSION, band, score
20
+
21
+ __all__ = ["RUBRIC", "assess", "VERSION", "band", "novelty", "report", "score", "similarity", "stems"]
rubric/novelty.py ADDED
@@ -0,0 +1,97 @@
1
+ """Novelty in this version: the authors' label and the close-match check (RUBRIC.md §6).
2
+
3
+ similarity(a, b) -> 0..1, the overlap of the stems of two claim statements
4
+ novelty(claim, matches, concept=None) -> {"label", "check", "matches"}
5
+
6
+ `matches` are the claims of claimsets published on EntryLib before this one that the site found as candidates, each
7
+ {"claim": "entrylib:2026.000014#C3", "statement": "..."}; the site finds candidates (by full-text search), this module
8
+ decides which match closely and whether the claim acknowledges them.
9
+ """
10
+
11
+ import re
12
+
13
+ CLOSE = 0.6
14
+
15
+ STOP = set("""
16
+ a about above after again against all also an and any are as at be because been before being below between both but by
17
+ can could did do does doing down during each either few for from further had has have having here how if in into is it
18
+ its itself just less more most much must no nor not of off on once only or other our out over own same shall should so
19
+ some such than that the their them then there these they this those through to too under until up upon very was we were
20
+ what when where whether which while who whom why will with within without would yet
21
+ """.split())
22
+
23
+ # Endings removed, longest first, when at least three letters remain (RUBRIC.md §6).
24
+ ENDINGS = ("ational", "ations", "ation", "ments", "ment", "ings", "ing", "ies", "ied", "ed", "es", "ly", "s")
25
+
26
+ TERM = re.compile(r"\[\[[^\]:]+:([^\]]*)\]\]")
27
+ MATH = re.compile(r"\$\$.+?\$\$|\$[^$]+\$|\\\(.+?\\\)", re.S)
28
+ WORD = re.compile(r"[^\W_]+")
29
+
30
+
31
+ def _stem(w):
32
+ for e in ENDINGS:
33
+ if w.endswith(e) and len(w) - len(e) >= 3:
34
+ return w[: -len(e)]
35
+ return w
36
+
37
+
38
+ def stems(statement):
39
+ """The set of stems of a statement: terms as their text, mathematics removed, stop words dropped."""
40
+ text = MATH.sub(" ", TERM.sub(lambda m: m.group(1), statement if isinstance(statement, str) else ""))
41
+ return {_stem(w) for w in WORD.findall(text.lower()) if w not in STOP}
42
+
43
+
44
+ def similarity(a, b):
45
+ """Overlap coefficient of the stems of two statements: shared stems over those of the shorter one."""
46
+ sa, sb = stems(a), stems(b)
47
+ if not sa or not sb:
48
+ return 0.0
49
+ return len(sa & sb) / min(len(sa), len(sb))
50
+
51
+
52
+ def _concept(global_id):
53
+ """entrylib:2026.000014@v2#C3 -> entrylib:2026.000014"""
54
+ return re.split(r"[@#]", global_id, maxsplit=1)[0] if isinstance(global_id, str) else ""
55
+
56
+
57
+ def novelty(claim, matches, concept=None):
58
+ """The claim's novelty label and the close-match check: check is "none" (no close match on EntryLib),
59
+ "acknowledged" (close matches, all related to by the claim) or "unacknowledged"; matches lists the close ones,
60
+ each with its similarity and whether it is acknowledged. Matches from the claim's own claimset (`concept`) are
61
+ ignored."""
62
+ label = claim.get("novelty")
63
+ targets = {r.get("target") for r in claim.get("relations") or [] if isinstance(r, dict)}
64
+ targets |= {_concept(t) for t in targets}
65
+ close = []
66
+ for m in matches or []:
67
+ gid = m.get("claim") if isinstance(m, dict) else None
68
+ if not isinstance(gid, str) or (concept and _concept(gid) == concept):
69
+ continue
70
+ s = similarity(claim.get("statement"), m.get("statement"))
71
+ if s >= CLOSE:
72
+ close.append({"claim": gid, "similarity": round(s, 2),
73
+ "acknowledged": gid in targets or _concept(gid) in targets})
74
+ close.sort(key=lambda m: -m["similarity"])
75
+ check = ("none" if not close else "acknowledged" if all(m["acknowledged"] for m in close)
76
+ else "unacknowledged")
77
+ return {"label": label if label != "not_applicable" else None, "check": check, "matches": close}
78
+
79
+
80
+ def assess(doc, find=None, warnings=None):
81
+ """score(doc) with `novelty`: {claim id: novelty() + `checked`} for every scored claim that has a novelty label.
82
+ Each principal claim is compared with the published claims that find(statement) returns ([{"claim", "statement"}],
83
+ supplied by the caller: the portal's search, or EntryLib's public search); `checked` says whether it was. Without
84
+ `find` nothing is compared and every `checked` is false. `warnings` are the claimset validator's, as for score()."""
85
+ from .score import score
86
+ result = score(doc, warnings)
87
+ concept = (doc.get("paper") or {}).get("id") if isinstance(doc.get("paper"), dict) else None
88
+ novelty_of = {}
89
+ for c in doc.get("claims") or []:
90
+ if not isinstance(c, dict) or c.get("id") not in result["claims"] or not c.get("novelty"):
91
+ continue
92
+ compared = find is not None and c.get("role") == "principal"
93
+ n = novelty(c, find(c.get("statement")) if compared else [], concept)
94
+ n["checked"] = compared
95
+ novelty_of[c["id"]] = n
96
+ result["novelty"] = novelty_of
97
+ return result
rubric/report.py ADDED
@@ -0,0 +1,84 @@
1
+ """The scores as a plain-text report (RUBRIC.md §4.3), for the authors and their agents: of a published claimset (the
2
+ portal's issues.txt) or of one not yet submitted (`claimset score`).
3
+
4
+ report(scores, biggest_n=0) -> the report; `biggest_n` > 0 adds the problems that cost one claim the most
5
+ open_problems(scores) -> [(id, problem, {claim: cost})] of the problems that lower a score, in rubric order
6
+ biggest(scores, n) -> the n problems that cost one claim the most, most costly first
7
+
8
+ `scores` is what score() or assess() returns; assess() adds `novelty`, and only claims it compared (`checked`) can be
9
+ in question.
10
+ """
11
+
12
+
13
+ def _sentence(words):
14
+ words = str(words)
15
+ return words if words.rstrip().endswith((".", "!", "?")) else words + "."
16
+
17
+
18
+ def _lowered(scores, pid, cid, cost):
19
+ """"C5 (-10)", or "C5 (-10, from C3)" for a problem the claim inherits from what it rests on (§2)."""
20
+ source = ((scores.get("claims") or {}).get(cid) or {}).get("inherited", {}).get(pid)
21
+ return f"{cid} (-{cost}, from {', '.join(source)})" if source else f"{cid} (-{cost})"
22
+
23
+
24
+ def open_problems(scores):
25
+ """[(id, problem, {claim id: cost})] of every problem that lowers some claim's score, in the order of score()
26
+ (those of the principal claims first, most costly first)."""
27
+ out = []
28
+ for pid, p in (scores.get("problems") or {}).items():
29
+ lowers = {cid: c["costs"][pid] for cid, c in (scores.get("claims") or {}).items() if pid in c["problems"]}
30
+ if lowers:
31
+ out.append((pid, p, lowers))
32
+ return out
33
+
34
+
35
+ def biggest(scores, n=3):
36
+ """The n problems that cost one claim the most, most costly first (ties keep the rubric's order)."""
37
+ return sorted(open_problems(scores), key=lambda x: -max(x[2].values()))[:n]
38
+
39
+
40
+ def report(scores, biggest_n=0, name="reliability"):
41
+ """The report of RUBRIC.md §4.3, as text ending in a newline. `name`: what the score is called, for a site that
42
+ shows it under another name (§4.3)."""
43
+ out = []
44
+ paper = scores.get("paper")
45
+ if paper and "lowest_score" in paper: # rubric 0.3: the mean, with the lowest shown
46
+ n = len(paper.get("principal") or [])
47
+ out.append(f"PAPER: {name} {paper['score']} ({paper['band']}), the mean of its {n} principal claim"
48
+ f"{'s' if n != 1 else ''}; the lowest is {paper['lowest']} at {paper['lowest_score']}.")
49
+ elif paper:
50
+ out.append(f"PAPER: {name} {paper['score']} ({paper['band']}), the score of its lowest principal claim, "
51
+ f"{paper['lowest']}. Raising the lowest principal claim raises the paper.")
52
+ else:
53
+ out.append("PAPER: no score (no principal claim is scored).")
54
+ out.append("")
55
+ top = biggest(scores, biggest_n) if biggest_n else []
56
+ if top:
57
+ out.append("BIGGEST ISSUES, most costly first:")
58
+ out += [f" {pid} ({', '.join(f'{c} -{cost}' for c, cost in lowers.items())}): {_sentence(p['text'])} "
59
+ f"To fix: {p['fix']}" for pid, p, lowers in top]
60
+ out.append("")
61
+ flagged = {cid: n for cid, n in (scores.get("novelty") or {}).items()
62
+ if isinstance(n, dict) and n.get("check") == "unacknowledged" and n.get("checked", True)}
63
+ if flagged:
64
+ out.append("NOVELTY IN QUESTION: close matches on EntryLib that the claim does not relate to:")
65
+ out += [f" {cid}: " + ", ".join(f"{m['claim']} (similarity {m['similarity']})" for m in n["matches"]
66
+ if not m["acknowledged"]) for cid, n in flagged.items()]
67
+ out.append("")
68
+ problems = open_problems(scores)
69
+ if not problems:
70
+ out.append("No issues: every scored claim keeps all its points.")
71
+ return "\n".join(out) + "\n"
72
+ out.append("ISSUES, those of the principal claims first, most costly first:")
73
+ for pid, p, lowers in problems:
74
+ out += ["", f"{pid}. {_sentence(p['text'])}",
75
+ f" Check: {p['check']} (group: {p['group']}); costs up to {p['cost']} points.",
76
+ f" Lowers: {', '.join(_lowered(scores, pid, c, cost) for c, cost in lowers.items())}."]
77
+ if p.get("objects"):
78
+ out.append(f" Concerns: {', '.join(p['objects'])}.")
79
+ out.append(f" To fix: {p['fix']}")
80
+ out += ["", "CLAIMS: score (band): issues"]
81
+ for cid, c in scores["claims"].items():
82
+ issues = ", ".join(f"{p} (-{c['costs'][p]})" for p in c["problems"]) or "none"
83
+ out.append(f"{cid}: {c['score']} ({c['band']}): {issues}")
84
+ return "\n".join(out) + "\n"
rubric/rubric.json ADDED
@@ -0,0 +1,101 @@
1
+ {
2
+ "version": "0.4",
3
+ "bands": [
4
+ {"name": "high", "label": "High", "min": 80},
5
+ {"name": "medium", "label": "Medium", "min": 60},
6
+ {"name": "low", "label": "Low", "min": 0}
7
+ ],
8
+ "groups": [
9
+ {"name": "support", "label": "Support", "about": "what the claim rests on: evidence, proof, citation, argument or judgement"},
10
+ {"name": "plan", "label": "Plan", "about": "whether the test was planned before it was run, and how the plan was anchored"},
11
+ {"name": "checkable", "label": "Checkable", "about": "whether a reader can obtain what is needed to check the claim"},
12
+ {"name": "context", "label": "Context", "about": "where the claim holds, what earlier work it relates to, and which recorded results its numbers come from"},
13
+ {"name": "open", "label": "Open problems", "about": "limitations and assumptions that are still open"}
14
+ ],
15
+ "dots": {"green_max": 2, "amber_max": 10},
16
+ "checks": {
17
+ "support_kind": {
18
+ "group": "support", "count": "kind",
19
+ "costs": {"evidence": 0, "evidence_unspecified": 10, "formal_checked": 0, "formal_unchecked": 25,
20
+ "formal_disputed": 45, "cited": 20, "consensus": 25, "argument_from_claims": 10, "argument": 30,
21
+ "expert_judgement": 45, "unstated": 70},
22
+ "text": "Rests on {what}",
23
+ "fix": "Support the claim with evidence the claimset records, citing the result entries or table rows it reports, or have its proof checked."
24
+ },
25
+ "exploratory_inference": {
26
+ "group": "plan", "count": "kind",
27
+ "costs": {"with_public_record": 5, "without_public_record": 15},
28
+ "text": "{type} claim tested without a plan recorded beforehand{record}",
29
+ "fix": "Test it again under a lab-record plan or a registration, or publish the lab record that shows every run."
30
+ },
31
+ "exploratory_principal": {
32
+ "group": "plan", "count": "kind",
33
+ "costs": {"with_public_record": 3, "without_public_record": 5},
34
+ "text": "A principal claim reported without a plan recorded beforehand{record}",
35
+ "fix": "Plan the measurement in the lab record or a registration before running it, or publish the lab record that shows every run."
36
+ },
37
+ "plan_anchor": {
38
+ "group": "plan", "count": "kind",
39
+ "costs": {"git_remote": 2, "none": 10},
40
+ "text": "The plan's time is anchored {how}",
41
+ "fix": "Anchor plans with the platform, a timestamp authority (RFC 3161) or a registry."
42
+ },
43
+ "plan_deviations": {
44
+ "group": "plan", "count": "kind", "costs": {"deviations": 5},
45
+ "text": "The test deviated from its plan",
46
+ "fix": "Each deviation is a limitation: say how it bears on the result."
47
+ },
48
+ "plan_flags": {
49
+ "group": "plan", "count": "kind", "cost_each": 5,
50
+ "text": "The lab record flags the test: {flags}",
51
+ "fix": "Rerun the test as planned, or explain the flag in a limitation."
52
+ },
53
+ "verifiability": {
54
+ "group": "checkable", "count": "kind",
55
+ "costs": {"audit_only": 10, "unverifiable": 45},
56
+ "text": "Verifiability: {level}",
57
+ "fix": "Make the data, code and models the claim rests on available, or state why they cannot be."
58
+ },
59
+ "verifiability_flags": {
60
+ "group": "checkable", "count": "kind", "cost_each": 5,
61
+ "text": "Flags: {flags}",
62
+ "fix": "Publish what each flag says is missing."
63
+ },
64
+ "no_scope": {
65
+ "group": "context", "count": "kind", "costs": {"missing": 10},
66
+ "text": "No scope: where the claim holds is not stated",
67
+ "fix": "Give the claim a scope: population, setting, period, data, conditions, exclusions."
68
+ },
69
+ "no_prior_work": {
70
+ "group": "context", "count": "kind", "costs": {"missing": 5},
71
+ "text": "A principal claim related to no earlier work",
72
+ "fix": "Relate the claim to the earlier work it extends, addresses or departs from."
73
+ },
74
+ "numbers_without_results": {
75
+ "group": "context", "count": "kind", "costs": {"missing": 5},
76
+ "text": "Numbers in the statement match no recorded result",
77
+ "fix": "Record each number as a result or table cell of the evidence the claim cites, and list it in the support's results; or correct the number."
78
+ },
79
+ "limitation": {
80
+ "group": "open", "count": "each",
81
+ "costs": {
82
+ "could_reverse": {"none": 10, "partial": 5, "addressed": 0},
83
+ "could_bias_magnitude": {"none": 6, "partial": 3, "addressed": 0},
84
+ "reduces_precision": {"none": 3, "partial": 1, "addressed": 0},
85
+ "reduces_transferability": {"none": 1, "partial": 0, "addressed": 0}
86
+ },
87
+ "text": "Limitation {id} ({impact}, {mitigation}): {statement}",
88
+ "fix": "Mitigate it with evidence, or narrow the claim's scope so that it no longer applies."
89
+ },
90
+ "no_limitation": {
91
+ "group": "open", "count": "kind", "costs": {"missing": 10},
92
+ "text": "A principal claim that declares no limitation",
93
+ "fix": "Declare what could bias, reverse or limit the claim within its scope, each as a limitation that affects it."
94
+ },
95
+ "unchecked_assumption": {
96
+ "group": "open", "count": "each", "cost_each": 3,
97
+ "text": "Assumption {id} is not checked: {statement}",
98
+ "fix": "Test the assumption, or analyse how sensitive the claim is to it."
99
+ }
100
+ }
101
+ }
rubric/score.py ADDED
@@ -0,0 +1,375 @@
1
+ """Reliability scores (RUBRIC.md §2–5).
2
+
3
+ score(doc, warnings=None) -> {
4
+ "rubric": "0.4",
5
+ "claims": {"C1": {"score": 94, "band": "high", "dots": {"support": "green", ...}, "problems": ["P3", ...],
6
+ "costs": {"P3": 6, ...}, "inherited": {"P5": ["C2"], ...}}, ...},
7
+ "assumptions": ["C24", ...], # not scored
8
+ "paper": {"score": 74, "band": "medium", "dots": {...}, "problems": [...], "principal": [...], "lowest": "C6",
9
+ "lowest_score": 60, "lowest_problems": [...]} or None when no principal claim is scored,
10
+ "problems": {"P1": {"check", "group", "cost", "claims", "arises_in", "objects", "text", "fix"}, ...},
11
+ }
12
+
13
+ `warnings` are the claimset validator's warnings for the file ([{rule, where, message}], its published validation
14
+ report), when given: warning 17 decides `numbers_without_results`, and a limitation with warning 46 (it restates the
15
+ declared competing interest) costs nothing.
16
+
17
+ A problem in "problems" is one aggregated problem (§3.6) over the principal claims and what they rest on (then over the
18
+ other claims); its "cost" is the most it costs any one claim, "claims" every claim it lowers and "arises_in" the claims
19
+ it is found on. A claim lists the problems that reach it, with what each costs that claim under "costs", and under
20
+ "inherited" the ones it has from what it rests on, with the claims they arise in. The paper's score is the mean of its
21
+ principal claims' (§5); "problems" of the paper are every problem open in its principal claims, for the explanation.
22
+ """
23
+
24
+ import json
25
+ import re
26
+ from importlib import resources
27
+
28
+ RUBRIC = json.loads(resources.files(__package__).joinpath("rubric.json").read_text(encoding="utf-8"))
29
+ VERSION = RUBRIC["version"]
30
+ CHECKS = RUBRIC["checks"]
31
+ GROUPS = [g["name"] for g in RUBRIC["groups"]]
32
+
33
+ EVIDENCE_KINDS = {"empirical_quant", "empirical_qual", "empirical_obs", "simulation", "demonstration"}
34
+ OBSERVATION_KINDS = {"empirical_quant", "empirical_obs", "simulation"} # evidence whose observations are cited
35
+ SPECIFIC = re.compile(r"\.R\d+$|\.T\d+\.r\d+$|#row=") # a result entry, a table row, data rows
36
+ STUDIED_ROLES = {"subject", "baseline"}
37
+ INFERENTIAL = {"associational", "causal", "mechanistic", "predictive"}
38
+ SUPPORT_WORDS = {"evidence": "evidence",
39
+ "evidence_unspecified": "evidence named without the observations the claim reports",
40
+ "argument_from_claims": "an argument from other claims",
41
+ "formal_checked": "a checked proof", "formal_unchecked": "an unchecked proof",
42
+ "formal_disputed": "a disputed proof", "cited": "cited work only", "consensus": "consensus only",
43
+ "argument": "argument only", "expert_judgement": "expert judgement only",
44
+ "unstated": "no stated support"}
45
+ TERM = re.compile(r"\[\[[^\]:]+:([^\]]*)\]\]")
46
+ MATH = re.compile(r"\$\$.+?\$\$|\$[^$]+\$|\\\(.+?\\\)", re.S)
47
+
48
+
49
+ def band(value):
50
+ """The band of a score: high, medium or low (§4.1)."""
51
+ if value is None:
52
+ return None
53
+ for b in RUBRIC["bands"]:
54
+ if value >= b["min"]:
55
+ return b["name"]
56
+ return RUBRIC["bands"][-1]["name"]
57
+
58
+
59
+ def _dot(points):
60
+ d = RUBRIC["dots"]
61
+ return "green" if points <= d["green_max"] else "amber" if points <= d["amber_max"] else "red"
62
+
63
+
64
+ def _list(x):
65
+ return x if isinstance(x, list) else []
66
+
67
+
68
+ def _dict(x):
69
+ return x if isinstance(x, dict) else {}
70
+
71
+
72
+ def is_assumption(c):
73
+ kinds = [s.get("kind") for s in _list(c.get("support")) if isinstance(s, dict)]
74
+ return bool(kinds) and all(k == "assumption" for k in kinds)
75
+
76
+
77
+ def _public_record(doc):
78
+ rid = _dict(_dict(doc.get("paper")).get("exploration")).get("lab_record")
79
+ for a in _list(doc.get("artefacts")):
80
+ if isinstance(a, dict) and a.get("id") == rid:
81
+ return _dict(a.get("availability")).get("level") == "public"
82
+ return False
83
+
84
+
85
+ def _cites_observations(s, evidence):
86
+ """Whether a support entry names the observations its claim reports (§3.1): a result entry, a table row or data
87
+ rows, or evidence that records no results or tables."""
88
+ if any(isinstance(r, str) and SPECIFIC.search(r) for r in _list(s.get("results"))):
89
+ return True
90
+ e = _dict(evidence.get(s.get("evidence")))
91
+ return bool(e) and not _list(e.get("results")) and not _list(e.get("tables"))
92
+
93
+
94
+ def _support_cost(c, by_id, evidence):
95
+ costs = CHECKS["support_kind"]["costs"]
96
+ best = None
97
+ for s in _list(c.get("support")):
98
+ kind = s.get("kind") if isinstance(s, dict) else None
99
+ if kind in OBSERVATION_KINDS:
100
+ key = "evidence" if _cites_observations(s, evidence) else "evidence_unspecified"
101
+ elif kind in EVIDENCE_KINDS:
102
+ key = "evidence"
103
+ elif kind == "formal_proof":
104
+ v = c.get("verification")
105
+ key = ("formal_checked" if v in ("machine_checked", "agent_checked", "peer_checked")
106
+ else "formal_disputed" if v == "disputed" else "formal_unchecked")
107
+ elif kind == "argument":
108
+ premises = [by_id.get(d) for d in _list(c.get("depends_on")) if isinstance(d, str)]
109
+ key = "argument_from_claims" if any(p and not is_assumption(p) for p in premises) else "argument"
110
+ elif kind in costs:
111
+ key = kind
112
+ else:
113
+ continue
114
+ if best is None or costs[key] < costs[best]:
115
+ best = key
116
+ return best
117
+
118
+
119
+ def _api_model_studied(c, doc, evidence):
120
+ """Whether every API-only model in the evidence the claim's support names is what is studied there (§3.3)."""
121
+ weights = {m.get("id"): m.get("weights") for m in _list(doc.get("models")) if isinstance(m, dict)}
122
+ roles = [_dict(dm).get("role") for s in _list(c.get("support")) if isinstance(s, dict)
123
+ for dm in _list(_dict(evidence.get(s.get("evidence"))).get("models"))
124
+ if weights.get(_dict(dm).get("model")) == "api_only"]
125
+ return bool(roles) and all(r in STUDIED_ROLES for r in roles)
126
+
127
+
128
+ NAME_WITH_DIGITS = re.compile(r"(?<![\w.])[A-Za-z0-9_]+(?:-[A-Za-z0-9_]+)*")
129
+
130
+
131
+ def _has_numbers(statement):
132
+ text = MATH.sub(" ", TERM.sub(" ", statement if isinstance(statement, str) else ""))
133
+ text = re.sub(r"\b[A-Z]{1,2}\d+(?:\.[A-Za-z]*\d+)*\b", " ", text) # object IDs: C4, E1.T1.r2
134
+ text = NAME_WITH_DIGITS.sub(lambda m: " " if re.search(r"[A-Za-z]", m.group(0)) else m.group(0), text) # GPT-4o
135
+ return re.search(r"\d", text) is not None
136
+
137
+
138
+ #: Planning values of claims planned before they were run (§3.2): tests, and estimates (claimset v0.42).
139
+ PLANNED = ("confirmatory", "confirmatory_with_deviations", "planned_estimate")
140
+
141
+
142
+ def _unplanned_cost(c, public_record):
143
+ """What the claim would cost for being exploratory (§3.2): the most its plan's problems may cost."""
144
+ record = "with_public_record" if public_record else "without_public_record"
145
+ if c.get("type") in INFERENTIAL:
146
+ return CHECKS["exploratory_inference"]["costs"][record]
147
+ if c.get("role") == "principal" and c.get("type") != "formal":
148
+ return CHECKS["exploratory_principal"]["costs"][record]
149
+ return 0
150
+
151
+
152
+ def _capped(plan, limit):
153
+ """A plan's problems cut so that together they cost at most `limit`, the costliest kept first (§3.2)."""
154
+ out = []
155
+ for check, key, cost, claims, args in sorted(plan, key=lambda i: -i[2]):
156
+ cost = min(cost, limit)
157
+ limit -= cost
158
+ if cost > 0:
159
+ out.append((check, key, cost, claims, args))
160
+ return out
161
+
162
+
163
+ def _instances(doc, c, by_id, public_record, evidence, warned):
164
+ """The problems of one claim itself: [(check, key, cost, objects, args)]. `warned` is the set of (rule, where) of
165
+ the validator's warnings, or None when they were not given."""
166
+ out = []
167
+ cid = c.get("id")
168
+ formal = c.get("type") == "formal"
169
+
170
+ key = _support_cost(c, by_id, evidence)
171
+ cost = CHECKS["support_kind"]["costs"].get(key, 0) if key else CHECKS["support_kind"]["costs"]["unstated"]
172
+ if cost:
173
+ out.append(("support_kind", cid, cost, [cid], {"what": SUPPORT_WORDS.get(key or "unstated")}))
174
+
175
+ planning = c.get("planning")
176
+ record = "; the lab record shows every run" if public_record else ""
177
+ if planning in (None, "exploratory"):
178
+ t = c.get("type")
179
+ cost = _unplanned_cost(c, public_record)
180
+ if t in INFERENTIAL:
181
+ out.append(("exploratory_inference", cid, cost, [cid],
182
+ {"type": ("An " if t[0] in "aeiou" else "A ") + t, "record": record}))
183
+ elif cost:
184
+ out.append(("exploratory_principal", cid, cost, [cid], {"type": t or "", "record": record}))
185
+ plan = []
186
+ if planning in PLANNED:
187
+ anchor = c.get("planned_at_anchor")
188
+ costs = CHECKS["plan_anchor"]["costs"]
189
+ if anchor == "git_remote" or anchor in (None, "none"):
190
+ how = "by a git push only" if anchor == "git_remote" else "by nothing"
191
+ plan.append(("plan_anchor", cid, costs["git_remote" if anchor == "git_remote" else "none"], [cid],
192
+ {"how": how}))
193
+ if planning == "confirmatory_with_deviations":
194
+ plan.append(("plan_deviations", cid, CHECKS["plan_deviations"]["costs"]["deviations"], [cid], {}))
195
+ flags = [f for f in _list(_dict(c.get("test")).get("flags")) + _list(_dict(c.get("estimate")).get("flags"))
196
+ if isinstance(f, str)]
197
+ if flags:
198
+ ch = CHECKS["plan_flags"]
199
+ plan.append(("plan_flags", cid, sum(repeated([ch["cost_each"]] * len(flags))), [cid],
200
+ {"flags": ", ".join(f.replace("_", " ") for f in flags)}))
201
+ out += _capped(plan, _unplanned_cost(c, public_record)) if planning in PLANNED else plan
202
+
203
+ level = c.get("verifiability")
204
+ if level in CHECKS["verifiability"]["costs"]:
205
+ out.append(("verifiability", cid, CHECKS["verifiability"]["costs"][level], [cid],
206
+ {"level": level.replace("_", " ")}))
207
+ vflags = [f for f in _list(c.get("verifiability_flags"))
208
+ if isinstance(f, str) and f not in ("unstated_support", "unanchored_plan")
209
+ and not (f == "api_model_dependency" and _api_model_studied(c, doc, evidence))]
210
+ if vflags:
211
+ ch = CHECKS["verifiability_flags"]
212
+ out.append(("verifiability_flags", cid, sum(repeated([ch["cost_each"]] * len(vflags))), [cid],
213
+ {"flags": ", ".join(f.replace("_", " ") for f in vflags)}))
214
+
215
+ if not formal and not _dict(c.get("scope")):
216
+ out.append(("no_scope", cid, CHECKS["no_scope"]["costs"]["missing"], [cid], {}))
217
+ if c.get("role") == "principal" and c.get("novelty") != "not_applicable":
218
+ outside = any(isinstance(r, dict) and r.get("id_type") != "local" for r in _list(c.get("relations")))
219
+ cited = any(isinstance(s, dict) and s.get("kind") == "cited" for s in _list(c.get("support")))
220
+ if not (outside or cited):
221
+ out.append(("no_prior_work", cid, CHECKS["no_prior_work"]["costs"]["missing"], [cid], {}))
222
+ if warned is not None:
223
+ unsourced = not formal and ("W17", f"claims:{cid}.statement") in warned
224
+ else:
225
+ unsourced = not formal and _has_numbers(c.get("statement")) and not any(
226
+ isinstance(s, dict) and _list(s.get("results")) for s in _list(c.get("support")))
227
+ if unsourced:
228
+ out.append(("numbers_without_results", cid, CHECKS["numbers_without_results"]["costs"]["missing"], [cid], {}))
229
+
230
+ lim_costs = CHECKS["limitation"]["costs"]
231
+ for lim in _list(doc.get("limitations")):
232
+ if isinstance(lim, dict) and cid in _list(lim.get("affects")) and not _restated(lim, warned):
233
+ cost = _dict(lim_costs.get(lim.get("impact"))).get(lim.get("mitigation") or "none", 0)
234
+ if cost:
235
+ out.append(("limitation", lim.get("id"), cost, [cid],
236
+ {"id": lim.get("id"), "impact": str(lim.get("impact", "")).replace("_", " "),
237
+ "mitigation": "not mitigated" if (lim.get("mitigation") or "none") == "none"
238
+ else f"mitigation {lim.get('mitigation')}", "statement": lim.get("statement", "")}))
239
+ for aid in _list(c.get("assumptions")):
240
+ a = by_id.get(aid)
241
+ if isinstance(a, dict) and a.get("check") == "unchecked":
242
+ out.append(("unchecked_assumption", aid, CHECKS["unchecked_assumption"]["cost_each"], [cid],
243
+ {"id": aid, "statement": a.get("statement", "")}))
244
+ return out
245
+
246
+
247
+ def _restated(lim, warned):
248
+ """A limitation that only restates the declared competing interest (warning 46; §3.5)."""
249
+ return warned is not None and ("W46", f"limitations:{lim.get('id')}.statement") in warned
250
+
251
+
252
+ def _closure(cid, by_id):
253
+ """The claim and everything it rests on (depends_on and assumptions), followed to the end."""
254
+ seen, stack = [], [cid]
255
+ while stack:
256
+ x = stack.pop()
257
+ if x in seen or x not in by_id:
258
+ continue
259
+ seen.append(x)
260
+ c = by_id[x]
261
+ stack.extend(i for i in _list(c.get("depends_on")) + _list(c.get("assumptions")) if isinstance(i, str))
262
+ return seen
263
+
264
+
265
+ def repeated(costs):
266
+ """The costs of repeated problems of one check on one claim (RUBRIC.md §3.6): sorted from the largest, the first
267
+ in full and each next one half the one before (rounded down, at least 1), so that every one removed or mitigated
268
+ lowers the total and the total stays below twice the largest."""
269
+ return [0 if c <= 0 else max(1, c // 2 ** i) for i, c in enumerate(sorted(costs, reverse=True))]
270
+
271
+
272
+ def _aggregate(instances):
273
+ """Instances counted as RUBRIC.md §3.6: {(check, key): {cost, claims, objects, args}}; key is None for `kind`."""
274
+ out = {}
275
+ for check, key, cost, claims, args in instances:
276
+ k = (check, None) if CHECKS[check]["count"] == "kind" else (check, key)
277
+ p = out.setdefault(k, {"check": check, "cost": 0, "claims": [], "args": args})
278
+ if cost > p["cost"]:
279
+ p["cost"], p["args"] = cost, args
280
+ for c in claims:
281
+ if c not in p["claims"]:
282
+ p["claims"].append(c)
283
+ for check in {k[0] for k in out if CHECKS[k[0]]["count"] == "each"}:
284
+ items = sorted((p for k, p in out.items() if k[0] == check), key=lambda p: -p["cost"])
285
+ for p, cost in zip(items, repeated([p["cost"] for p in items])):
286
+ p["cost"] = cost
287
+ return out
288
+
289
+
290
+ def _sort_claims(ids):
291
+ return sorted(ids, key=lambda i: (len(i), i))
292
+
293
+
294
+ def _result(agg):
295
+ total = sum(p["cost"] for p in agg.values())
296
+ value = max(0, 100 - total)
297
+ lost = {g: 0 for g in GROUPS}
298
+ for p in agg.values():
299
+ lost[CHECKS[p["check"]]["group"]] += p["cost"]
300
+ return value, {g: _dot(v) for g, v in lost.items()}
301
+
302
+
303
+ def score(doc, warnings=None):
304
+ """The reliability scores of a published claimset (RUBRIC.md §2–5), given the validator's warnings for it when
305
+ they are available (its validation report)."""
306
+ claims = [c for c in _list(doc.get("claims")) if isinstance(c, dict) and isinstance(c.get("id"), str)]
307
+ by_id = {c["id"]: c for c in claims}
308
+ evidence = {e.get("id"): e for e in _list(doc.get("evidence")) if isinstance(e, dict)}
309
+ warned = None if warnings is None else {(_dict(w).get("rule"), _dict(w).get("where")) for w in _list(warnings)}
310
+ public_record = _public_record(doc)
311
+ own = {c["id"]: _instances(doc, c, by_id, public_record, evidence, warned) for c in claims if not is_assumption(c)}
312
+ limited = {cid for lim in _list(doc.get("limitations")) if isinstance(lim, dict) and not _restated(lim, warned)
313
+ for cid in _list(lim.get("affects")) if isinstance(cid, str)}
314
+ for c in claims: # no_limitation (§3.5): nothing it rests on declares one
315
+ if (c["id"] in own and c.get("role") == "principal" and c.get("type") != "formal"
316
+ and not limited & set(_closure(c["id"], by_id))):
317
+ own[c["id"]].append(("no_limitation", c["id"], CHECKS["no_limitation"]["costs"]["missing"], [c["id"]], {}))
318
+
319
+ def gathered(ids):
320
+ return [i for x in ids for cid in _closure(x, by_id) for i in own.get(cid, [])]
321
+
322
+ principal = [c["id"] for c in claims if c.get("role") == "principal" and c["id"] in own]
323
+ paper_agg = _aggregate(gathered(principal)) if principal else {}
324
+ everything = _aggregate(gathered(list(own)))
325
+ # Problem IDs: those of the paper first, by cost, then the rest; stable for a given file.
326
+ order = sorted(paper_agg, key=lambda k: (-paper_agg[k]["cost"], k[0], str(k[1])))
327
+ order += sorted((k for k in everything if k not in paper_agg), key=lambda k: (k[0], str(k[1])))
328
+ pid = {k: f"P{i + 1}" for i, k in enumerate(order)}
329
+
330
+ def describe(k, p):
331
+ ch = CHECKS[k[0]]
332
+ try:
333
+ text = ch["text"].format(**p["args"])
334
+ except (KeyError, IndexError):
335
+ text = ch["text"]
336
+ return {"check": k[0], "group": ch["group"], "cost": p["cost"], "claims": _sort_claims(p["claims"]),
337
+ "arises_in": _sort_claims(p["claims"]),
338
+ "objects": [k[1]] if k[1] is not None else [], "text": text, "fix": ch["fix"]}
339
+
340
+ problems = {pid[k]: describe(k, paper_agg.get(k) or everything[k]) for k in order}
341
+
342
+ out_claims = {}
343
+ for cid in own:
344
+ agg = _aggregate(gathered([cid]))
345
+ mine = set(_aggregate(own[cid]))
346
+ value, dots = _result(agg)
347
+ listed = sorted(agg, key=lambda k: (-agg[k]["cost"], pid[k]))
348
+ out_claims[cid] = {"score": value, "band": band(value), "dots": dots,
349
+ "problems": [pid[k] for k in listed if agg[k]["cost"]],
350
+ "costs": {pid[k]: agg[k]["cost"] for k in listed if agg[k]["cost"]},
351
+ "inherited": {pid[k]: problems[pid[k]]["arises_in"] for k in listed
352
+ if agg[k]["cost"] and k not in mine}}
353
+
354
+ # A problem's cost is the most it costs any one claim (a repeated problem may cost one claim less than another);
355
+ # its claims are every claim it lowers, as the claims' own lists say (§2: inherited problems).
356
+ for k in order:
357
+ lowered = [cid for cid, c in out_claims.items() if pid[k] in c["costs"]]
358
+ if lowered:
359
+ problems[pid[k]]["cost"] = max(out_claims[c]["costs"][pid[k]] for c in lowered)
360
+ problems[pid[k]]["claims"] = _sort_claims(lowered)
361
+
362
+ paper = None
363
+ if principal:
364
+ # The paper's score is the mean of its principal claims' (§5), shown with the lowest; its dots, the worst of
365
+ # each group among them.
366
+ lowest = min(principal, key=lambda c: (out_claims[c]["score"], len(c), c))
367
+ value = int(sum(out_claims[c]["score"] for c in principal) / len(principal) + 0.5)
368
+ rank = {"green": 0, "amber": 1, "red": 2}
369
+ dots = {g: max((out_claims[c]["dots"][g] for c in principal), key=rank.get) for g in GROUPS}
370
+ listed = [k for k in order if k in paper_agg and paper_agg[k]["cost"]]
371
+ paper = {"score": value, "band": band(value), "dots": dots, "problems": [pid[k] for k in listed],
372
+ "principal": principal, "lowest": lowest, "lowest_score": out_claims[lowest]["score"],
373
+ "lowest_problems": out_claims[lowest]["problems"]}
374
+ return {"rubric": VERSION, "claims": out_claims,
375
+ "assumptions": [c["id"] for c in claims if is_assumption(c)], "paper": paper, "problems": problems}