hypgraph 0.1.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.
- hypgraph/__init__.py +28 -0
- hypgraph/__main__.py +3 -0
- hypgraph/bundle.py +216 -0
- hypgraph/checks.py +140 -0
- hypgraph/cli.py +445 -0
- hypgraph/demos/__init__.py +3 -0
- hypgraph/demos/broad_street.py +283 -0
- hypgraph/derive.py +390 -0
- hypgraph/graph.py +1002 -0
- hypgraph/mcp.py +461 -0
- hypgraph/plan.py +328 -0
- hypgraph/policy.py +51 -0
- hypgraph/schema.py +141 -0
- hypgraph/server.py +202 -0
- hypgraph/skill/SKILL.md +94 -0
- hypgraph/store.py +139 -0
- hypgraph/ui/__init__.py +7 -0
- hypgraph/ui/atlas.css +219 -0
- hypgraph/ui/atlas.js +1052 -0
- hypgraph/ui/hypgraph.svg +10 -0
- hypgraph/ui/index.html +30 -0
- hypgraph/ui/vendor/LICENSES.md +10 -0
- hypgraph/ui/vendor/cose-base.js +3214 -0
- hypgraph/ui/vendor/cytoscape-fcose.js +1549 -0
- hypgraph/ui/vendor/cytoscape.min.js +32 -0
- hypgraph/ui/vendor/layout-base.js +5230 -0
- hypgraph-0.1.0.dist-info/METADATA +140 -0
- hypgraph-0.1.0.dist-info/RECORD +31 -0
- hypgraph-0.1.0.dist-info/WHEEL +4 -0
- hypgraph-0.1.0.dist-info/entry_points.txt +2 -0
- hypgraph-0.1.0.dist-info/licenses/LICENSE +201 -0
hypgraph/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HypGraph — a research record that can't flatter itself.
|
|
3
|
+
|
|
4
|
+
Don't believe the hype. Test the hyp.
|
|
5
|
+
|
|
6
|
+
>>> from hypgraph import Graph, Policy
|
|
7
|
+
>>> g = Graph(policy=Policy(reviewers={"ada"})) # in memory; Graph("study.hypgraph") for a file
|
|
8
|
+
>>> me = g.author("sam")
|
|
9
|
+
>>> inv = me.investigation("Plant growth", "Find out whether light colour changes how fast cress grows.")["id"]
|
|
10
|
+
>>> h = me.add("hypothesis", inv, statement="Blue light makes cress grow faster than red light.",
|
|
11
|
+
... predictions=["Blue-lit trays are taller after 7 days."])
|
|
12
|
+
>>> h["state"]["sentence"]
|
|
13
|
+
'active hypothesis · unchecked · no live support · unverified report · no external replication'
|
|
14
|
+
|
|
15
|
+
Standing is derived on every read, never stored; every write is an event in a SHA-256 hash chain; claims carry
|
|
16
|
+
their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
from .bundle import verify_bundle # noqa: E402
|
|
22
|
+
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
23
|
+
from .policy import Policy # noqa: E402
|
|
24
|
+
from .schema import FORMAT, describe # noqa: E402
|
|
25
|
+
from .store import verify_file # noqa: E402
|
|
26
|
+
|
|
27
|
+
__all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe",
|
|
28
|
+
"FORMAT", "__version__"]
|
hypgraph/__main__.py
ADDED
hypgraph/bundle.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bundles: a record you can hand to someone else, who can check it without trusting you.
|
|
3
|
+
|
|
4
|
+
``export()`` writes the whole graph; ``export(inv)`` one investigation. Every event is included as the **exact string
|
|
5
|
+
its hash was taken over**, and every version body and edge's data as the exact string stored, so verifying is just
|
|
6
|
+
SHA-256 over strings plus a few lookups — any language can do it (the site does it in the browser with WebCrypto).
|
|
7
|
+
|
|
8
|
+
{"format": "hypgraph/1-bundle", "complete": true, "scope": {...}, "policy": {...},
|
|
9
|
+
"events": [{"seq", "hash", "body"}], # body = canonical JSON of {at, actor, kind, ref, payload, prev}
|
|
10
|
+
"nodes": [{"id", "type", "code", "inv", "created_at", "created_by"}],
|
|
11
|
+
"versions": [{"node", "v", "at", "by", "reason", "body"}], # body = the stored canonical string
|
|
12
|
+
"edges": [{"id", "src", "src_v", "rel", "dst", "dst_v", "data", "at", "by", "why"}], # data likewise
|
|
13
|
+
"withdrawals": [...], "retractions": [...], "leases": [...],
|
|
14
|
+
"derived": {...}} # standing at export time, for viewers; never trusted
|
|
15
|
+
|
|
16
|
+
A scoped bundle (one investigation out of a shared graph) redacts the events outside it to {seq, hash, prev}: their
|
|
17
|
+
content can't be checked, so a change hidden among them (a withdrawal, say) would not show. ``verify_bundle`` says so
|
|
18
|
+
(``complete: false``). To publish a study, keep it in a graph of its own and export all of it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from . import __version__
|
|
28
|
+
from .store import GENESIS, event_body, now
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .graph import Graph
|
|
32
|
+
|
|
33
|
+
BUNDLE_FORMAT = "hypgraph/1-bundle"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sha(s: str) -> str:
|
|
37
|
+
return hashlib.sha256(s.encode()).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def export(g: "Graph", inv: str = "", *, derived: bool = True) -> dict:
|
|
41
|
+
with g._snap_lock:
|
|
42
|
+
con = g._connect()
|
|
43
|
+
rows = lambda q, *a: [dict(zip([c[0] for c in cur.description], r)) # noqa: E731
|
|
44
|
+
for cur in [con.execute(q, a)] for r in cur.fetchall()]
|
|
45
|
+
nodes = rows("SELECT id, type, code, inv, created_at, created_by FROM nodes ORDER BY rowid")
|
|
46
|
+
if inv:
|
|
47
|
+
if not any(n["id"] == inv and n["type"] == "investigation" for n in nodes):
|
|
48
|
+
raise KeyError(inv)
|
|
49
|
+
nodes = [n for n in nodes if n["id"] == inv or n["inv"] == inv]
|
|
50
|
+
ids = {n["id"] for n in nodes}
|
|
51
|
+
versions = [v for v in rows("SELECT node, v, at, by, reason, body FROM versions ORDER BY rowid")
|
|
52
|
+
if v["node"] in ids]
|
|
53
|
+
edges = [e for e in rows("SELECT id, src, src_v, rel, dst, dst_v, data, at, by, why FROM edges ORDER BY id")
|
|
54
|
+
if e["src"] in ids and e["dst"] in ids]
|
|
55
|
+
eids = {e["id"] for e in edges}
|
|
56
|
+
withdrawals = [w for w in rows("SELECT node, at, by, why FROM withdrawals ORDER BY rowid") if w["node"] in ids]
|
|
57
|
+
retractions = [r for r in rows("SELECT edge, at, by, why FROM retractions ORDER BY rowid") if r["edge"] in eids]
|
|
58
|
+
leases = [x for x in rows("SELECT id, node, agent, task, kind, at, expires, note FROM leases ORDER BY id")
|
|
59
|
+
if x["node"] in ids]
|
|
60
|
+
events, redacted = [], 0
|
|
61
|
+
for r in rows("SELECT seq, at, actor, kind, ref, payload, prev, hash FROM events ORDER BY seq"):
|
|
62
|
+
p = json.loads(r["payload"])
|
|
63
|
+
mine = not inv or _event_in(r["kind"], r["ref"], p, ids, eids)
|
|
64
|
+
if mine:
|
|
65
|
+
events.append({"seq": r["seq"], "hash": r["hash"],
|
|
66
|
+
"body": event_body(r["at"], r["actor"], r["kind"], r["ref"], p, r["prev"])})
|
|
67
|
+
else:
|
|
68
|
+
redacted += 1
|
|
69
|
+
events.append({"seq": r["seq"], "hash": r["hash"], "prev": r["prev"], "redacted": True})
|
|
70
|
+
title = next((json.loads(v["body"]).get("title") for v in versions if v["node"] == inv), "") if inv else ""
|
|
71
|
+
out: dict[str, Any] = {
|
|
72
|
+
"format": BUNDLE_FORMAT, "generator": f"hypgraph {__version__}",
|
|
73
|
+
"exported_at": now(),
|
|
74
|
+
"complete": redacted == 0, "redacted_events": redacted,
|
|
75
|
+
"scope": {"inv": inv, "title": title} if inv else {"inv": "", "title": "whole graph"},
|
|
76
|
+
"policy": g.policy.to_dict(), "head": events[-1]["hash"] if events else GENESIS,
|
|
77
|
+
"events": events, "nodes": nodes, "versions": versions, "edges": edges,
|
|
78
|
+
"withdrawals": withdrawals, "retractions": retractions, "leases": leases,
|
|
79
|
+
}
|
|
80
|
+
if derived:
|
|
81
|
+
out["derived"] = derive_views(g, [inv] if inv else [n["id"] for n in nodes if n["type"] == "investigation"])
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _event_in(kind: str, ref: str, p: dict, ids: set, eids: set) -> bool:
|
|
86
|
+
if kind in ("link", "retract"):
|
|
87
|
+
return int(p.get("edge", -1)) in eids or (kind == "link" and ref in ids and p.get("dst") in ids)
|
|
88
|
+
return ref in ids
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def derive_views(g: "Graph", invs: list[str]) -> dict:
|
|
92
|
+
"""What a viewer needs without running the engine: standing of every node and the per-investigation views."""
|
|
93
|
+
views = {"investigations": [i for i in g.investigations() if i["id"] in invs], "stats": g.stats(), "by_inv": {}}
|
|
94
|
+
for i in invs:
|
|
95
|
+
views["by_inv"][i] = {"subgraph": g.subgraph(i), "work": g.work_map(i), "next": g.next_experiments(i, 20),
|
|
96
|
+
"report": g.report(i)}
|
|
97
|
+
views["verify"] = g.verify()
|
|
98
|
+
return views
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def verify_bundle(b: dict) -> dict:
|
|
102
|
+
"""Check a bundle string-for-string: the chain, then every row against the event that wrote it."""
|
|
103
|
+
if not isinstance(b, dict) or b.get("format") != BUNDLE_FORMAT:
|
|
104
|
+
return {"ok": False, "problem": f"not a {BUNDLE_FORMAT} bundle"}
|
|
105
|
+
prev, checked, red = GENESIS, 0, 0
|
|
106
|
+
creates, revs, reviews, links, withdraws, retracts = {}, {}, {}, {}, set(), set()
|
|
107
|
+
for ev in sorted(b.get("events") or [], key=lambda e: e["seq"]):
|
|
108
|
+
if ev.get("redacted"):
|
|
109
|
+
if ev.get("prev") != prev:
|
|
110
|
+
return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (redacted event)"}
|
|
111
|
+
prev, red = ev["hash"], red + 1
|
|
112
|
+
continue
|
|
113
|
+
if _sha(ev["body"]) != ev["hash"]:
|
|
114
|
+
return {"ok": False, "broken_at": ev["seq"], "problem": "event body does not match its hash"}
|
|
115
|
+
body = json.loads(ev["body"])
|
|
116
|
+
if body.get("prev") != prev:
|
|
117
|
+
return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (prev does not match)"}
|
|
118
|
+
prev, checked = ev["hash"], checked + 1
|
|
119
|
+
k, ref, p = body["kind"], body["ref"], body["payload"]
|
|
120
|
+
if k == "create":
|
|
121
|
+
creates[ref] = p
|
|
122
|
+
revs[(ref, int(p.get("v", 1)))] = p.get("body_sha")
|
|
123
|
+
elif k == "revise":
|
|
124
|
+
revs[(ref, int(p.get("v")))] = p.get("body_sha")
|
|
125
|
+
elif k == "review":
|
|
126
|
+
reviews[p["review"]] = p
|
|
127
|
+
revs[(p["review"], 1)] = p.get("body_sha")
|
|
128
|
+
elif k == "link":
|
|
129
|
+
links[int(p["edge"])] = p
|
|
130
|
+
elif k == "withdraw":
|
|
131
|
+
withdraws.add(ref)
|
|
132
|
+
elif k == "retract":
|
|
133
|
+
retracts.add(int(p["edge"]))
|
|
134
|
+
if b.get("head") and b["head"] != prev:
|
|
135
|
+
return {"ok": False, "problem": "the chain does not end at the bundle's head"}
|
|
136
|
+
for n in b.get("nodes") or []:
|
|
137
|
+
c = creates.get(n["id"])
|
|
138
|
+
if n["type"] == "review":
|
|
139
|
+
if n["id"] not in reviews:
|
|
140
|
+
return {"ok": False, "problem": f"review {n['id']} has no event"}
|
|
141
|
+
elif not c or (c.get("type"), c.get("code"), c.get("inv")) != (n["type"], n["code"], n["inv"]):
|
|
142
|
+
return {"ok": False, "problem": f"node {n['id']} differs from its create event"}
|
|
143
|
+
for v in b.get("versions") or []:
|
|
144
|
+
want = revs.get((v["node"], v["v"]))
|
|
145
|
+
if want is None:
|
|
146
|
+
return {"ok": False, "problem": f"version {v['node']} v{v['v']} has no event"}
|
|
147
|
+
if _sha(v["body"]) != want:
|
|
148
|
+
return {"ok": False, "problem": f"version {v['node']} v{v['v']} differs from its event"}
|
|
149
|
+
for e in b.get("edges") or []:
|
|
150
|
+
want = links.get(e["id"])
|
|
151
|
+
if want is None:
|
|
152
|
+
return {"ok": False, "problem": f"edge {e['id']} has no event"}
|
|
153
|
+
if (want.get("rel"), want.get("dst"), want.get("dst_v")) != (e["rel"], e["dst"], e["dst_v"]) or \
|
|
154
|
+
_sha(e["data"]) != want.get("data_sha"):
|
|
155
|
+
return {"ok": False, "problem": f"edge {e['id']} differs from its event"}
|
|
156
|
+
for w in b.get("withdrawals") or []:
|
|
157
|
+
if w["node"] not in withdraws:
|
|
158
|
+
return {"ok": False, "problem": f"withdrawal of {w['node']} has no event"}
|
|
159
|
+
for r in b.get("retractions") or []:
|
|
160
|
+
if int(r["edge"]) not in retracts:
|
|
161
|
+
return {"ok": False, "problem": f"retraction of edge {r['edge']} has no event"}
|
|
162
|
+
out = {"ok": True, "checked": checked, "head": prev, "complete": red == 0, "redacted": red,
|
|
163
|
+
"nodes": len(b.get("nodes") or []), "edges": len(b.get("edges") or [])}
|
|
164
|
+
if red:
|
|
165
|
+
out["warning"] = (f"{red} event(s) outside this investigation are redacted: their content can't be checked, so "
|
|
166
|
+
"a change hidden among them (a withdrawal, say) would not show. The full record rules that "
|
|
167
|
+
"out.")
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load(g: "Graph", b: dict) -> None:
|
|
172
|
+
"""Replay a complete bundle into an empty graph, row for row — the result verifies exactly as the original."""
|
|
173
|
+
v = verify_bundle(b)
|
|
174
|
+
if not v["ok"]:
|
|
175
|
+
raise ValueError(f"bundle does not verify: {v['problem']}")
|
|
176
|
+
if not v["complete"]:
|
|
177
|
+
raise ValueError("a scoped bundle has redacted events and can't be replayed; view it, or ask for the full one")
|
|
178
|
+
with g._lock:
|
|
179
|
+
con = g._connect()
|
|
180
|
+
if con.execute("SELECT COUNT(*) FROM events").fetchone()[0]:
|
|
181
|
+
raise ValueError("load a bundle into an empty graph")
|
|
182
|
+
con.execute("BEGIN IMMEDIATE")
|
|
183
|
+
try:
|
|
184
|
+
for n in b["nodes"]:
|
|
185
|
+
con.execute("INSERT INTO nodes (id, type, code, inv, created_at, created_by) VALUES (?,?,?,?,?,?)",
|
|
186
|
+
(n["id"], n["type"], n["code"], n["inv"], n["created_at"], n["created_by"]))
|
|
187
|
+
for x in b["versions"]:
|
|
188
|
+
con.execute("INSERT INTO versions (node, v, at, by, reason, body) VALUES (?,?,?,?,?,?)",
|
|
189
|
+
(x["node"], x["v"], x["at"], x["by"], x["reason"], x["body"]))
|
|
190
|
+
for e in b["edges"]:
|
|
191
|
+
con.execute("INSERT INTO edges (id, src, src_v, rel, dst, dst_v, data, at, by, why) "
|
|
192
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?)", (e["id"], e["src"], e["src_v"], e["rel"], e["dst"],
|
|
193
|
+
e["dst_v"], e["data"], e["at"], e["by"], e["why"]))
|
|
194
|
+
for w in b["withdrawals"]:
|
|
195
|
+
con.execute("INSERT INTO withdrawals (node, at, by, why) VALUES (?,?,?,?)",
|
|
196
|
+
(w["node"], w["at"], w["by"], w["why"]))
|
|
197
|
+
for r in b["retractions"]:
|
|
198
|
+
con.execute("INSERT INTO retractions (edge, at, by, why) VALUES (?,?,?,?)",
|
|
199
|
+
(r["edge"], r["at"], r["by"], r["why"]))
|
|
200
|
+
for x in b["leases"]:
|
|
201
|
+
con.execute("INSERT INTO leases (id, node, agent, task, kind, at, expires, note) "
|
|
202
|
+
"VALUES (?,?,?,?,?,?,?,?)", (x["id"], x["node"], x["agent"], x["task"], x["kind"],
|
|
203
|
+
x["at"], x["expires"], x["note"]))
|
|
204
|
+
for ev in b["events"]:
|
|
205
|
+
body = json.loads(ev["body"])
|
|
206
|
+
con.execute("INSERT INTO events (seq, at, actor, kind, ref, payload, prev, hash) "
|
|
207
|
+
"VALUES (?,?,?,?,?,?,?,?)", (ev["seq"], body["at"], body["actor"], body["kind"],
|
|
208
|
+
body["ref"], json.dumps(body["payload"], sort_keys=True,
|
|
209
|
+
ensure_ascii=False,
|
|
210
|
+
separators=(",", ":")),
|
|
211
|
+
body["prev"], ev["hash"]))
|
|
212
|
+
con.execute("COMMIT")
|
|
213
|
+
except BaseException:
|
|
214
|
+
con.execute("ROLLBACK")
|
|
215
|
+
raise
|
|
216
|
+
g._invalidate()
|
hypgraph/checks.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mechanical checks on a claim before it enters the record. Each rule exists because the mistake behind it was made:
|
|
3
|
+
|
|
4
|
+
- **every number accounted for** — a number in the statement must match a measurement bound to a result field, the
|
|
5
|
+
scope, or one of the investigation's declared constants ("all 49 in 6 of 6" when the result said 5 of 6);
|
|
6
|
+
- **absolutes need k of n** — "never", "all", "proves" need a measurement whose k is 0 or n;
|
|
7
|
+
- **exclusions need power** — "X is ruled out" needs a search shown to recover a planted answer, else it's
|
|
8
|
+
inconclusive;
|
|
9
|
+
- **conventions don't hedge** — agreed notation isn't "could be";
|
|
10
|
+
- **scope and evidence** — a finding says under what conditions it holds, and links what supports it.
|
|
11
|
+
|
|
12
|
+
Spelled-out two … twenty count as numbers ("two practice ciphers" when the record had one). Digits in quotes are
|
|
13
|
+
names ('63 63 63', "04"), not quantities.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from .derive import Snap, producing_run
|
|
22
|
+
|
|
23
|
+
_NUM = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?(\s*%)?(?![\w])")
|
|
24
|
+
_ABSOLUTE = re.compile(r"\b(never|always|all|every|none|impossible|cannot|can't|proves?|proven|certainly|"
|
|
25
|
+
r"guaranteed?)\b", re.I)
|
|
26
|
+
_HEDGE = re.compile(r"\b(could|might|may|likely|probably|perhaps|possibly|suggests?|consistent with|"
|
|
27
|
+
r"aligns? with|plausibl\w*)\b", re.I)
|
|
28
|
+
_WORDS = {w: i for i, w in enumerate("two three four five six seven eight nine ten eleven twelve thirteen fourteen "
|
|
29
|
+
"fifteen sixteen seventeen eighteen nineteen twenty".split(), start=2)}
|
|
30
|
+
_WORDNUM = re.compile(r"\b(" + "|".join(_WORDS) + r")\b", re.I) # 'one' is left out: too common as a pronoun
|
|
31
|
+
_LABEL = re.compile(r"""(['"`‘“])[0-9][0-9 ]{0,30}(['"`’”])""")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def numbers(text: str) -> list[tuple[float, int, bool, str]]:
|
|
35
|
+
"""(value, decimals printed, is_percent, as printed) for each number in a text."""
|
|
36
|
+
out = []
|
|
37
|
+
for m in _NUM.finditer(text or ""):
|
|
38
|
+
whole, frac, pct = m.group(1), m.group(2), m.group(3)
|
|
39
|
+
v = float(whole.replace(",", "") + ("." + frac if frac else ""))
|
|
40
|
+
out.append((v, len(frac or ""), bool(pct), m.group(0).strip()))
|
|
41
|
+
for m in _WORDNUM.finditer(text or ""):
|
|
42
|
+
out.append((float(_WORDS[m.group(1).lower()]), 0, False, m.group(0)))
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _matches(printed: tuple, candidates: list[float]) -> bool:
|
|
47
|
+
v, dec, pct, _ = printed
|
|
48
|
+
for c in candidates:
|
|
49
|
+
for x in ((c, c * 100) if pct or c <= 1 else (c,)):
|
|
50
|
+
if round(x, dec) == v or (dec == 0 and abs(x - v) < 0.5 and round(x) == v):
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _measure_values(m: dict) -> list[float]:
|
|
56
|
+
vals = []
|
|
57
|
+
for k in ("value", "k", "n", "lo", "hi"):
|
|
58
|
+
x = m.get(k)
|
|
59
|
+
if isinstance(x, (int, float)) and not isinstance(x, bool):
|
|
60
|
+
vals.append(float(x))
|
|
61
|
+
for x in m.get("interval") or []:
|
|
62
|
+
if isinstance(x, (int, float)):
|
|
63
|
+
vals.append(float(x))
|
|
64
|
+
return vals
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def result_value(s: Snap, rid: str, key: str) -> Optional[dict]:
|
|
68
|
+
r = s.nodes.get(rid)
|
|
69
|
+
if not r or r["type"] != "result":
|
|
70
|
+
return None
|
|
71
|
+
v = (r["body"].get("values") or {}).get(key)
|
|
72
|
+
if isinstance(v, (int, float)):
|
|
73
|
+
return {"value": v}
|
|
74
|
+
return v if isinstance(v, dict) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def claim_problems(s: Snap, body: dict, support: list[str], inv: str) -> list[str]:
|
|
78
|
+
"""What's wrong with a claim, as instructions to fix it (empty = it may enter the record). ``support``: the ids
|
|
79
|
+
of nodes that support it or that it is derived from."""
|
|
80
|
+
problems = []
|
|
81
|
+
kind = body.get("kind") or "finding"
|
|
82
|
+
stmt = body.get("statement", "")
|
|
83
|
+
scope = body.get("scope", "")
|
|
84
|
+
invb = s.nodes.get(inv, {}).get("body", {}) if inv else {}
|
|
85
|
+
if invb.get("strict_claims") is False:
|
|
86
|
+
return []
|
|
87
|
+
if kind in ("finding", "exclusion") and len(scope.strip()) < 10:
|
|
88
|
+
problems.append("scope: say under which conditions this holds (the data, method, sample) — at least a sentence")
|
|
89
|
+
if kind == "convention" and _HEDGE.search(stmt):
|
|
90
|
+
problems.append("a convention states agreed notation; a hedged interpretation (could / suggests / consistent "
|
|
91
|
+
"with) is a finding or a lead")
|
|
92
|
+
support = [x for x in support if x in s.nodes]
|
|
93
|
+
if kind in ("finding", "exclusion", "inconclusive") and not support:
|
|
94
|
+
problems.append("link the evidence: at least one result, claim or source that supports it "
|
|
95
|
+
"(links: {\"rel\": \"supports\", \"from\": <result id>, \"why\": …})")
|
|
96
|
+
meas = body.get("measurements") or []
|
|
97
|
+
coverage: list[float] = []
|
|
98
|
+
for i, m in enumerate(meas):
|
|
99
|
+
if not isinstance(m, dict) or not m.get("result") or not m.get("key"):
|
|
100
|
+
problems.append(f"measurement {i + 1}: give {{name, value (or k/n), result: <result id>, key: <field>}}")
|
|
101
|
+
continue
|
|
102
|
+
rv = result_value(s, str(m["result"]), str(m["key"]))
|
|
103
|
+
if rv is None:
|
|
104
|
+
problems.append(f"measurement {m.get('name') or i + 1}: result {m['result']} has no value '{m['key']}'")
|
|
105
|
+
continue
|
|
106
|
+
for k in ("value", "k", "n"):
|
|
107
|
+
if k in m and k in rv and isinstance(m[k], (int, float)) and isinstance(rv[k], (int, float)):
|
|
108
|
+
dec = len(str(m[k]).split(".")[1]) if "." in str(m[k]) else 0
|
|
109
|
+
if round(float(rv[k]), dec) != round(float(m[k]), dec) and not (
|
|
110
|
+
rv[k] <= 1 < m[k] and round(float(rv[k]) * 100, dec) == round(float(m[k]), dec)):
|
|
111
|
+
problems.append(f"measurement {m.get('name') or i + 1}: says {k}={m[k]} but result {m['result']} "
|
|
112
|
+
f"has {rv[k]}")
|
|
113
|
+
coverage += _measure_values(m) + _measure_values(rv)
|
|
114
|
+
allowed = coverage + [v for v, *_ in numbers(scope)] + [float(c) for c in (invb.get("constants") or [])
|
|
115
|
+
if isinstance(c, (int, float))]
|
|
116
|
+
loose = [p for p in numbers(_LABEL.sub(" ", stmt)) if not _matches(p, allowed)]
|
|
117
|
+
if loose:
|
|
118
|
+
problems.append("numbers in the statement that no bound measurement, the scope or the investigation's "
|
|
119
|
+
f"constants account for: {', '.join(p[3] for p in loose[:8])} — bind them to result fields "
|
|
120
|
+
"(measurements) or move design constants into the scope")
|
|
121
|
+
for w in {m.group(0).lower() for m in _ABSOLUTE.finditer(stmt)}:
|
|
122
|
+
exact = any(isinstance(m, dict) and m.get("n") and m.get("k") in (0, m.get("n")) for m in meas)
|
|
123
|
+
if not exact:
|
|
124
|
+
problems.append(f"'{w}' is absolute: back it with a k/n measurement where k is 0 or n, and keep the "
|
|
125
|
+
"scope to the trials actually run — or say 'rarely' / 'in k of n'")
|
|
126
|
+
if kind == "exclusion":
|
|
127
|
+
powered = False
|
|
128
|
+
for sid in support:
|
|
129
|
+
src = s.nodes[sid]
|
|
130
|
+
if src["type"] == "result" and src["body"].get("power") == "demonstrated":
|
|
131
|
+
powered = True
|
|
132
|
+
run = producing_run(s, src["id"]) if src["type"] == "result" else None
|
|
133
|
+
for e in s.out.get(run or "", []):
|
|
134
|
+
if e["rel"] == "executes" and s.nodes[e["dst"]]["body"].get("power_check"):
|
|
135
|
+
powered = True
|
|
136
|
+
if not powered:
|
|
137
|
+
problems.append("an exclusion needs a search that recovered a planted answer (a result with "
|
|
138
|
+
"power=demonstrated, or a run of an experiment with a power_check) — otherwise file it "
|
|
139
|
+
"as inconclusive")
|
|
140
|
+
return problems
|