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/plan.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Method and coordination, all deterministic and read-only: which experiment to run next, who is on what, what
|
|
3
|
+
needs a reviewer or a revalidation, what changed, what it cost, and the facts of an investigation as a report
|
|
4
|
+
nobody can edit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from . import derive as D
|
|
16
|
+
from .schema import NEGATIVE, POSITIVE
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .graph import Graph
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _live_hypotheses(s: D.Snap, inv: str) -> list[str]:
|
|
23
|
+
return [i for i, n in s.nodes.items() if n["type"] == "hypothesis" and n["inv"] == inv and not D.out_of_play(s, i)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _executed(s: D.Snap, xid: str) -> list[str]:
|
|
27
|
+
return [e["src"] for e in s.inn.get(xid, []) if e["rel"] == "executes"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _method_key(s: D.Snap, xid: str) -> tuple:
|
|
31
|
+
b = s.nodes[xid]["body"]
|
|
32
|
+
tested = tuple(sorted(e["dst"] for e in s.out.get(xid, []) if e["rel"] == "tests"))
|
|
33
|
+
return (tested, re.sub(r"\W+", " ", str(b.get("method") or "").lower()).strip())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def duplicates(s: D.Snap, inv: str) -> list[list[str]]:
|
|
37
|
+
"""Live experiments testing the same hypotheses by the same method."""
|
|
38
|
+
groups: dict[tuple, list[str]] = defaultdict(list)
|
|
39
|
+
for i, n in s.nodes.items():
|
|
40
|
+
if n["type"] == "experiment" and n["inv"] == inv and not D.out_of_play(s, i):
|
|
41
|
+
k = _method_key(s, i)
|
|
42
|
+
if k[0] and k[1]:
|
|
43
|
+
groups[k].append(i)
|
|
44
|
+
return [g for g in groups.values() if len(g) > 1]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def next_experiments(g: "Graph", inv: str, limit: int = 10) -> dict:
|
|
48
|
+
"""Planned experiments ranked by how well they discriminate between live hypotheses, whether they can show their
|
|
49
|
+
own power, and cost — duplicates and leased work flagged. Plus live hypotheses that no experiment tests yet."""
|
|
50
|
+
s = g.snapshot()
|
|
51
|
+
live = set(_live_hypotheses(s, inv))
|
|
52
|
+
dup = {x for grp in duplicates(s, inv) for x in grp[1:]}
|
|
53
|
+
rows = []
|
|
54
|
+
for i, n in s.nodes.items():
|
|
55
|
+
if n["type"] != "experiment" or n["inv"] != inv or D.out_of_play(s, i):
|
|
56
|
+
continue
|
|
57
|
+
runs = _executed(s, i)
|
|
58
|
+
if any(s.nodes[r]["body"].get("state") in ("succeeded", "running", "queued") for r in runs):
|
|
59
|
+
continue
|
|
60
|
+
tests = [e for e in s.out.get(i, []) if e["rel"] == "tests" and e["dst"] in live]
|
|
61
|
+
expected = {str(e["data"].get("expected", "")).strip().lower() for e in tests}
|
|
62
|
+
separates = len(tests) if len(expected) > 1 else (1 if tests else 0)
|
|
63
|
+
powered = bool(n["body"].get("power_check"))
|
|
64
|
+
cost = float(n["body"].get("cost_minutes") or 60)
|
|
65
|
+
score = separates * (1.5 if powered else 1.0) / math.log2(2 + cost)
|
|
66
|
+
rows.append({"experiment": g.brief(s, i), "separates": separates, "power_check": powered,
|
|
67
|
+
"cost_minutes": cost, "score": round(score, 3), "duplicate": i in dup,
|
|
68
|
+
"lease": D.state(s, i).get("lease")})
|
|
69
|
+
rows.sort(key=lambda r: (r["duplicate"], r["lease"] is not None, -r["score"]))
|
|
70
|
+
tested = {e["dst"] for x, n in s.nodes.items() if n["type"] == "experiment" and n["inv"] == inv
|
|
71
|
+
for e in s.out.get(x, []) if e["rel"] == "tests"}
|
|
72
|
+
gaps = [g.brief(s, h) for h in live if h not in tested]
|
|
73
|
+
return {"ranked": rows[:limit], "untested_hypotheses": gaps,
|
|
74
|
+
"rule": "score = hypotheses it separates (distinct expected outcomes) × 1.5 if it can show its own power "
|
|
75
|
+
"÷ log2(2 + cost in minutes); duplicates and leased work last"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def work_map(g: "Graph", inv: str = "") -> dict:
|
|
79
|
+
"""Who is on what, what needs a reviewer, what needs revalidation, contested work, decisions to revisit and
|
|
80
|
+
duplicated experiments."""
|
|
81
|
+
s = g.snapshot()
|
|
82
|
+
inside = (lambda n: n["inv"] == inv) if inv else (lambda n: True)
|
|
83
|
+
leases = [{"node": g.brief(s, k), "agent": v["agent"], "task": v["task"], "expires": v["expires"]}
|
|
84
|
+
for k, v in s.leases.items() if k in s.nodes and inside(s.nodes[k])]
|
|
85
|
+
review_q, reval, contested, revisit = [], [], [], []
|
|
86
|
+
for i, n in s.nodes.items():
|
|
87
|
+
if inside(n) and n["type"] == "decision" and D.lifecycle(s, i) == "active":
|
|
88
|
+
st = D.state(s, i)
|
|
89
|
+
if st.get("basis_changed") or st.get("at_risk"):
|
|
90
|
+
revisit.append(g.brief(s, i))
|
|
91
|
+
continue
|
|
92
|
+
if not inside(n) or n["type"] not in ("claim", "result") or D.lifecycle(s, i) != "active":
|
|
93
|
+
continue
|
|
94
|
+
st = D.state(s, i)
|
|
95
|
+
rv = st["review"]["state"]
|
|
96
|
+
if rv == "needs_revalidation" or st.get("at_risk") or st.get("unsupported") or st.get("rests_on_superseded"):
|
|
97
|
+
reval.append(g.brief(s, i)) # something it rests on moved: first, before any review
|
|
98
|
+
elif rv == "contested":
|
|
99
|
+
contested.append(g.brief(s, i))
|
|
100
|
+
elif rv == "unchecked" and n["type"] == "claim" and n["body"].get("kind") != "lead":
|
|
101
|
+
review_q.append(g.brief(s, i))
|
|
102
|
+
dups = []
|
|
103
|
+
for invid in ({inv} if inv else {n["inv"] for n in s.nodes.values() if n["inv"]}):
|
|
104
|
+
dups += [[g.brief(s, x) for x in grp] for grp in duplicates(s, invid)]
|
|
105
|
+
return {"leases": leases, "review_queue": review_q[:200], "revalidation": reval[:200],
|
|
106
|
+
"contested": contested[:200], "decisions_to_revisit": revisit[:200], "duplicates": dups}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def digest(g: "Graph", since: str = "", inv: str = "") -> dict:
|
|
110
|
+
"""What changed in the record since ``since`` (ISO time; default the last 24 h) and what is waiting now, in
|
|
111
|
+
investigations still open (published and archived ones are fixed history)."""
|
|
112
|
+
s = g.snapshot()
|
|
113
|
+
since = since or (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat(timespec="seconds")
|
|
114
|
+
open_invs = {i: n for i, n in s.nodes.items() if n["type"] == "investigation"
|
|
115
|
+
and (n["body"].get("state") or "active") == "active" and (not inv or i == inv)}
|
|
116
|
+
created: dict[str, list] = defaultdict(list)
|
|
117
|
+
reviews = {"positive": 0, "negative": 0, "objections": 0, "comments": 0}
|
|
118
|
+
withdrawn = []
|
|
119
|
+
for e in s.events:
|
|
120
|
+
if e["at"] < since:
|
|
121
|
+
continue
|
|
122
|
+
ref, kind, pl = e["ref"], e["kind"], e["payload"]
|
|
123
|
+
n = s.nodes.get(ref)
|
|
124
|
+
if kind == "create" and n and (n["inv"] in open_invs or ref in open_invs):
|
|
125
|
+
created[n["type"]].append(g.brief(s, ref))
|
|
126
|
+
elif kind == "review" and n and n["inv"] in open_invs:
|
|
127
|
+
v = pl.get("verdict")
|
|
128
|
+
key = ("positive" if v in POSITIVE else "negative" if v in NEGATIVE else
|
|
129
|
+
"objections" if v == "objection" else "comments")
|
|
130
|
+
reviews[key] += 1
|
|
131
|
+
elif kind == "withdraw" and n and n["inv"] in open_invs:
|
|
132
|
+
withdrawn.append(g.brief(s, ref))
|
|
133
|
+
waiting = {"review_queue": 0, "revalidation": 0, "contested": [], "decisions_to_revisit": []}
|
|
134
|
+
for i in open_invs:
|
|
135
|
+
w = work_map(g, i)
|
|
136
|
+
waiting["review_queue"] += len(w["review_queue"])
|
|
137
|
+
waiting["revalidation"] += len(w["revalidation"])
|
|
138
|
+
waiting["contested"] += w["contested"]
|
|
139
|
+
waiting["decisions_to_revisit"] += w["decisions_to_revisit"]
|
|
140
|
+
keep = ("id", "code", "type", "inv", "title", "by", "at")
|
|
141
|
+
short = lambda rows: [{k: r[k] for k in keep} | {"sentence": r["state"]["sentence"]} for r in rows][:20] # noqa
|
|
142
|
+
|
|
143
|
+
def spent(i: str) -> dict:
|
|
144
|
+
if g.spend is None:
|
|
145
|
+
return {}
|
|
146
|
+
try:
|
|
147
|
+
c = cost(g, i)
|
|
148
|
+
return {"usd": c["usd"], "calls": c["calls"], "last_moved": c["last_moved"],
|
|
149
|
+
"since_last_moved_usd": c["since_last_moved"]["usd"]}
|
|
150
|
+
except Exception as e: # noqa: BLE001 - a spend problem never hides the record
|
|
151
|
+
return {"spend_error": f"{type(e).__name__}: {e}"[:200]}
|
|
152
|
+
return {"since": since, "investigations": [{"id": i, "title": n["body"].get("title", i), **spent(i)}
|
|
153
|
+
for i, n in open_invs.items()],
|
|
154
|
+
"new": {t: len(v) for t, v in created.items()}, "new_claims": short(created.get("claim", [])),
|
|
155
|
+
"new_decisions": short(created.get("decision", [])),
|
|
156
|
+
"lab_runs": sum(1 for r in created.get("run", []) if r["by"] == g.policy.instrument),
|
|
157
|
+
"reviews": reviews, "withdrawn": short(withdrawn),
|
|
158
|
+
"waiting": {"review_queue": waiting["review_queue"], "revalidation": waiting["revalidation"],
|
|
159
|
+
"contested": short(waiting["contested"]),
|
|
160
|
+
"decisions_to_revisit": short(waiting["decisions_to_revisit"])}}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _minutes(a: str, b: str) -> float:
|
|
164
|
+
try:
|
|
165
|
+
return max(0.0, (datetime.fromisoformat(str(b)) - datetime.fromisoformat(str(a))).total_seconds() / 60)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
return 0.0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_NO_SPEND = {"usd": 0.0, "calls": 0, "input_tokens": 0, "cached_tokens": 0, "output_tokens": 0, "by_task": []}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cost(g: "Graph", inv: str, nid: str = "") -> dict:
|
|
174
|
+
"""What an investigation has cost, derived on read: the spend hook's figures for every task that wrote to it
|
|
175
|
+
(events carry the writer's task — Graph.writing_for), instrument minutes from its runs, $ per review verdict, and
|
|
176
|
+
what was spent since the record last moved. A node's figure is the cost of the tasks that worked on it — shared
|
|
177
|
+
work, so node figures overlap and don't add up to the total."""
|
|
178
|
+
s = g.snapshot()
|
|
179
|
+
n = s.nodes.get(inv)
|
|
180
|
+
if not n or n["type"] != "investigation":
|
|
181
|
+
raise KeyError(inv)
|
|
182
|
+
|
|
183
|
+
def in_inv(ref: str) -> bool:
|
|
184
|
+
return ref == inv or s.nodes.get(ref, {}).get("inv") == inv
|
|
185
|
+
node_tasks: dict[str, set] = defaultdict(set)
|
|
186
|
+
reviews, last_moved = 0, ""
|
|
187
|
+
for e in s.events:
|
|
188
|
+
if not in_inv(e["ref"]):
|
|
189
|
+
continue
|
|
190
|
+
t = (e["payload"] or {}).get("task")
|
|
191
|
+
if t:
|
|
192
|
+
node_tasks[e["ref"]].add(t)
|
|
193
|
+
if e["kind"] == "review":
|
|
194
|
+
reviews += 1
|
|
195
|
+
if not e["kind"].startswith("lease"):
|
|
196
|
+
last_moved = max(last_moved, e["at"])
|
|
197
|
+
tasks = set().union(*node_tasks.values()) if node_tasks else set()
|
|
198
|
+
metered = g.spend is not None
|
|
199
|
+
led = dict(g.spend(inv, tasks)) if metered else dict(_NO_SPEND)
|
|
200
|
+
by_task = {r["task_id"]: r["usd"] for r in led.pop("by_task", []) if r.get("task_id")}
|
|
201
|
+
lab_runs = [m for m in s.nodes.values() if m["type"] == "run" and m["inv"] == inv
|
|
202
|
+
and m["created_by"] == g.policy.instrument]
|
|
203
|
+
lab_minutes = round(sum(_minutes(r["body"].get("started"), r["body"].get("finished")) for r in lab_runs), 1)
|
|
204
|
+
moved = (g.spend(inv, tasks, since=last_moved) if (metered and last_moved) else led)
|
|
205
|
+
|
|
206
|
+
def node_cost(i: str) -> dict:
|
|
207
|
+
ids = sorted(node_tasks.get(i, ()))
|
|
208
|
+
return {"usd": round(sum(by_task.get(t) or 0 for t in ids), 6), "tasks": ids}
|
|
209
|
+
ranked = sorted(((i, node_cost(i)) for i in node_tasks if i in s.nodes and s.nodes[i]["type"] in
|
|
210
|
+
("claim", "result", "hypothesis", "experiment", "decision")), key=lambda x: -x[1]["usd"])
|
|
211
|
+
out = {"inv": inv, "title": n["body"].get("title", inv), **led, "metered": metered,
|
|
212
|
+
"lab": {"runs": len(lab_runs), "minutes": lab_minutes},
|
|
213
|
+
"reviews": reviews, "usd_per_verdict": round(led["usd"] / reviews, 4) if reviews and metered else None,
|
|
214
|
+
"last_moved": last_moved or None,
|
|
215
|
+
"since_last_moved": {"usd": moved["usd"], "calls": moved["calls"]},
|
|
216
|
+
"tasks": len(by_task) if metered else len(tasks),
|
|
217
|
+
"top_nodes": [{**_brief_min(s, i), **c} for i, c in ranked[:10]],
|
|
218
|
+
"note": ("Spend comes from the host's meter. Node figures are the tasks that worked on each node; they "
|
|
219
|
+
"overlap and don't sum to the total." if metered else
|
|
220
|
+
"No spend meter is connected (Graph(spend=…)); only lab minutes and review counts are shown.")}
|
|
221
|
+
if nid:
|
|
222
|
+
if nid not in s.nodes:
|
|
223
|
+
raise KeyError(nid)
|
|
224
|
+
out["node"] = {**_brief_min(s, nid), **node_cost(nid)}
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _brief_min(s: D.Snap, i: str) -> dict:
|
|
229
|
+
m = s.nodes[i]
|
|
230
|
+
b = m["body"]
|
|
231
|
+
return {"id": i, "code": m.get("code", ""), "type": m["type"],
|
|
232
|
+
"title": str(b.get("title") or b.get("statement") or b.get("text") or b.get("summary") or i)[:160]}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def report(g: "Graph", inv: str) -> dict:
|
|
236
|
+
"""The facts of an investigation, generated from the record, for the top of any summary: every claim with its
|
|
237
|
+
lifecycle and review verdicts, every result and whether a claim carries it (results aren't reviewed — a
|
|
238
|
+
conclusion that lives only in a result hasn't been checked), hypotheses, experiments (preregistered or not), open
|
|
239
|
+
items and spend. Deterministic; nobody writes it, so it can't restate a withdrawn claim or override a verdict."""
|
|
240
|
+
s = g.snapshot()
|
|
241
|
+
n = s.nodes.get(inv)
|
|
242
|
+
if not n or n["type"] != "investigation":
|
|
243
|
+
raise KeyError(inv)
|
|
244
|
+
mine = [i for i, m in s.nodes.items() if m["inv"] == inv]
|
|
245
|
+
by_type: dict[str, list] = defaultdict(list)
|
|
246
|
+
for i in sorted(mine, key=lambda x: s.nodes[x]["created_at"]):
|
|
247
|
+
by_type[s.nodes[i]["type"]].append(i)
|
|
248
|
+
|
|
249
|
+
def verdicts(i: str) -> list[str]:
|
|
250
|
+
out = []
|
|
251
|
+
for e in s.inn.get(i, []):
|
|
252
|
+
r = s.nodes.get(e["src"]) if e["rel"] == "reviews" else None
|
|
253
|
+
if r and r["id"] not in s.withdrawn:
|
|
254
|
+
out.append(f"{r['created_by']}: {r['body'].get('verdict')} ({r['code']}, on v{e['dst_v'] or 0})")
|
|
255
|
+
return out
|
|
256
|
+
cited_by_claim = {e["src"] for c in by_type["claim"] for e in s.inn.get(c, []) if e["rel"] == "supports"}
|
|
257
|
+
claims = []
|
|
258
|
+
for c in by_type["claim"]:
|
|
259
|
+
st = D.state(s, c)
|
|
260
|
+
claims.append({**_brief_min(s, c), "lifecycle": st["lifecycle"], "review": (st.get("review") or {}).get("state"),
|
|
261
|
+
"verdicts": verdicts(c), "v": s.nodes[c]["v"], "evidence": st.get("evidence"),
|
|
262
|
+
"lines": st.get("independent_lines")})
|
|
263
|
+
results = [{**_brief_min(s, r), "outcome": D.state(s, r).get("outcome"), "by": s.nodes[r]["created_by"],
|
|
264
|
+
"in_a_claim": r in cited_by_claim, "lifecycle": D.state(s, r)["lifecycle"]} for r in by_type["result"]]
|
|
265
|
+
hyps = [{**_brief_min(s, h), "lifecycle": D.state(s, h)["lifecycle"],
|
|
266
|
+
"support": D.state(s, h).get("independent_lines"), "challenges": D.state(s, h).get("challenges")}
|
|
267
|
+
for h in by_type["hypothesis"]]
|
|
268
|
+
exps = []
|
|
269
|
+
for x in by_type["experiment"]:
|
|
270
|
+
tests = [e for e in s.out.get(x, []) if e["rel"] == "tests"]
|
|
271
|
+
exps.append({**_brief_min(s, x), "preregistered": bool(tests) and all((e.get("data") or {}).get("expected")
|
|
272
|
+
for e in tests),
|
|
273
|
+
"tests": [s.nodes[e["dst"]]["code"] for e in tests if e["dst"] in s.nodes],
|
|
274
|
+
"executed": bool(_executed(s, x)), "lifecycle": D.state(s, x)["lifecycle"]})
|
|
275
|
+
live = [c for c in claims if c["lifecycle"] != "withdrawn"]
|
|
276
|
+
open_items = {
|
|
277
|
+
"claims_unreviewed": [c["code"] for c in live if c["review"] in (None, "unchecked", "needs_revalidation")],
|
|
278
|
+
"claims_contested": [c["code"] for c in live if c["review"] == "contested"],
|
|
279
|
+
"results_in_no_claim": [r["code"] for r in results if not r["in_a_claim"] and r["lifecycle"] != "withdrawn"
|
|
280
|
+
and r["by"] != g.policy.instrument],
|
|
281
|
+
"experiments_not_preregistered": [x["code"] for x in exps if not x["preregistered"]],
|
|
282
|
+
}
|
|
283
|
+
try:
|
|
284
|
+
spend = cost(g, inv)
|
|
285
|
+
spend = {k: spend[k] for k in ("usd", "calls", "reviews", "usd_per_verdict", "last_moved", "metered")}
|
|
286
|
+
except Exception as e: # noqa: BLE001 - a spend problem never hides the record
|
|
287
|
+
spend = {"error": f"{type(e).__name__}: {e}"[:200]}
|
|
288
|
+
out = {"inv": inv, "code": n["code"], "title": n["body"].get("title", inv),
|
|
289
|
+
"state": n["body"].get("state") or "active", "objective": n["body"].get("objective", ""),
|
|
290
|
+
"claims": claims, "results": results, "hypotheses": hyps, "experiments": exps,
|
|
291
|
+
"open": open_items, "spend": spend, "seq": s.seq}
|
|
292
|
+
out["markdown"] = report_markdown(out)
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def report_markdown(r: dict) -> str:
|
|
297
|
+
cut = lambda t, k=160: (t[:k] + "…") if len(t) > k else t # noqa: E731
|
|
298
|
+
L = [f"## Facts from the research graph — {r['code']} {r['title']} ({r['state']})",
|
|
299
|
+
f"_Generated from the record at event {r['seq']}; do not edit — regenerate it from the graph. "
|
|
300
|
+
"Interpretation goes below this section._", "", "### Claims", "",
|
|
301
|
+
"| claim | standing | review | verdicts | statement |", "|---|---|---|---|---|"]
|
|
302
|
+
for c in r["claims"]:
|
|
303
|
+
L.append(f"| {c['code']} v{c['v']} | {c['lifecycle']} | {c['review'] or '-'} | "
|
|
304
|
+
f"{'; '.join(c['verdicts']) or '-'} | {cut(c['title'])} |")
|
|
305
|
+
L += ["", "### Results", "", "| result | outcome | by | carried by a claim | summary |", "|---|---|---|---|---|"]
|
|
306
|
+
for x in r["results"]:
|
|
307
|
+
L.append(f"| {x['code']} | {x['outcome'] or '-'} | {x['by']} | {'yes' if x['in_a_claim'] else 'NO'} | "
|
|
308
|
+
f"{cut(x['title'])} |")
|
|
309
|
+
L += ["", "### Hypotheses", ""]
|
|
310
|
+
L += [f"- {h['code']} ({h['lifecycle']}; support lines {h['support']}, challenges {h['challenges']}): "
|
|
311
|
+
f"{cut(h['title'], 140)}" for h in r["hypotheses"]] or ["- none"]
|
|
312
|
+
L += ["", "### Experiments", ""]
|
|
313
|
+
L += [f"- {x['code']} tests {', '.join(x['tests']) or '-'}: "
|
|
314
|
+
f"{'preregistered' if x['preregistered'] else 'NOT preregistered'}, "
|
|
315
|
+
f"{'run' if x['executed'] else 'not run'} — {cut(x['title'], 120)}" for x in r["experiments"]] or ["- none"]
|
|
316
|
+
o = r["open"]
|
|
317
|
+
L += ["", "### Open items", "",
|
|
318
|
+
f"- claims not yet reviewed: {', '.join(o['claims_unreviewed']) or 'none'}",
|
|
319
|
+
f"- claims contested: {', '.join(o['claims_contested']) or 'none'}",
|
|
320
|
+
f"- results no claim carries (their conclusions are unreviewed): {', '.join(o['results_in_no_claim']) or 'none'}",
|
|
321
|
+
f"- experiments not preregistered: {', '.join(o['experiments_not_preregistered']) or 'none'}"]
|
|
322
|
+
sp = r["spend"]
|
|
323
|
+
if "error" in sp:
|
|
324
|
+
L += ["", "### Spend", "", f"- unavailable ({sp.get('error')})"]
|
|
325
|
+
elif sp.get("metered"):
|
|
326
|
+
L += ["", "### Spend", "", f"- ${sp['usd']:.2f} over {sp['calls']} model calls; {sp['reviews']} review "
|
|
327
|
+
"verdict(s)"]
|
|
328
|
+
return "\n".join(L)
|
hypgraph/policy.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Who the record trusts for what. These are the host application's decisions — HypGraph enforces them but never
|
|
3
|
+
authenticates anyone: whoever calls ``Graph.create(author=...)`` is taken at their word, so a host exposing the graph
|
|
4
|
+
to others (an HTTP API, an MCP server, a team of agents) must fix ``author`` itself and refuse the reserved names.
|
|
5
|
+
|
|
6
|
+
reviewers whose verdicts count (official reviews). An investigation can add its own (field ``reviewers``).
|
|
7
|
+
instrument the author whose ``kind=lab`` runs are records the instrument made (a lab job runner) — the only
|
|
8
|
+
author that can file a lab-recorded run and what it produced.
|
|
9
|
+
operator the person in charge: revises anything, settles disputes, withdraws and retracts.
|
|
10
|
+
importer brings in a legacy record: may attribute a node to its original author, isn't held to the claim
|
|
11
|
+
checks (the old record is imported as written), settles disputes like the operator.
|
|
12
|
+
system reserved for the host's own bookkeeping.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Policy:
|
|
22
|
+
reviewers: frozenset = field(default_factory=frozenset)
|
|
23
|
+
instrument: str = "lab"
|
|
24
|
+
operator: str = "operator"
|
|
25
|
+
importer: str = "importer"
|
|
26
|
+
system: str = "system"
|
|
27
|
+
max_creates_per_minute: int = 60
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
object.__setattr__(self, "reviewers", frozenset(self.reviewers))
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def curators(self) -> frozenset:
|
|
34
|
+
"""Operator and importer: may revise anything, settle disputes, and aren't held to the claim checks."""
|
|
35
|
+
return frozenset({self.operator, self.importer})
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def reserved(self) -> frozenset:
|
|
39
|
+
"""Names a host must never let an outside caller write as."""
|
|
40
|
+
return frozenset({self.instrument, self.operator, self.importer, self.system})
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
return {"reviewers": sorted(self.reviewers), "instrument": self.instrument, "operator": self.operator,
|
|
44
|
+
"importer": self.importer, "system": self.system,
|
|
45
|
+
"max_creates_per_minute": self.max_creates_per_minute}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, d: dict) -> "Policy":
|
|
49
|
+
d = dict(d or {})
|
|
50
|
+
return cls(reviewers=frozenset(d.pop("reviewers", ()) or ()),
|
|
51
|
+
**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
hypgraph/schema.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The vocabulary of a HypGraph record (format ``hypgraph/1``): node types and their fields, the relations that may
|
|
3
|
+
join them, review verdicts and the ranks of evidence. Plain data, so other implementations can read it — and so
|
|
4
|
+
``hypgraph schema`` can print it as JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
FORMAT = "hypgraph/1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _f(kind: str, n: int = 2000, req: bool = False) -> tuple:
|
|
13
|
+
"""A field: (kind, max length, required). Kinds: str, text, list, dict, enum:a|b, bool, num, any."""
|
|
14
|
+
return (kind, n, req)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
POSITIVE = {"checked", "pass", "reproduced", "replicated", "separate_implementation", "generalizes"}
|
|
18
|
+
NEGATIVE = {"fail", "not_reproduced", "not_replicated", "does_not_generalize"}
|
|
19
|
+
VERDICTS = POSITIVE | NEGATIVE | {"objection", "resolved", "inconclusive", "comment"}
|
|
20
|
+
|
|
21
|
+
PROFILE_RANK = ["unverified", "agent_reported", "lab_recorded", "project_rerun", "separate_implementation",
|
|
22
|
+
"new_data", "external"]
|
|
23
|
+
PROFILE_LABEL = {"unverified": "unverified report", "agent_reported": "agent-reported run",
|
|
24
|
+
"lab_recorded": "lab-recorded run", "project_rerun": "reproduced in project",
|
|
25
|
+
"separate_implementation": "separate implementation", "new_data": "replicated on new data",
|
|
26
|
+
"external": "external source"}
|
|
27
|
+
|
|
28
|
+
TYPES: dict[str, dict] = {
|
|
29
|
+
"investigation": {"prefix": "INV", "fields": {
|
|
30
|
+
"title": _f("str", 200, True), "objective": _f("text", 3000, True), "success_criteria": _f("text", 3000),
|
|
31
|
+
"scope": _f("text", 3000), "state": _f("enum:active|archived|published"), "reviewers": _f("list", 20),
|
|
32
|
+
"constants": _f("list", 60), "strict_claims": _f("bool"), "keywords": _f("list", 30), "projects": _f("list", 20),
|
|
33
|
+
"doi": _f("str", 120), "note": _f("text", 2000), "legacy_case": _f("str", 80)}},
|
|
34
|
+
"question": {"prefix": "Q", "fields": {"text": _f("text", 1500, True), "why": _f("text", 1500)}},
|
|
35
|
+
"hypothesis": {"prefix": "H", "fields": {
|
|
36
|
+
"statement": _f("text", 1500, True), "assumptions": _f("list", 20), "predictions": _f("list", 20, True),
|
|
37
|
+
"distinguishing": _f("list", 20), "open_questions": _f("list", 20)}},
|
|
38
|
+
"claim": {"prefix": "CL", "fields": {
|
|
39
|
+
"statement": _f("text", 1500, True), "scope": _f("text", 1000), "limitations": _f("text", 1500),
|
|
40
|
+
"kind": _f("enum:finding|exclusion|inconclusive|convention|lead"), "measurements": _f("list", 40),
|
|
41
|
+
"evidence_note": _f("text", 1500), "legacy": _f("dict", 4000)}},
|
|
42
|
+
"experiment": {"prefix": "X", "fields": {
|
|
43
|
+
"aim": _f("text", 1500, True), "protocol": _f("text", 4000, True), "controls": _f("text", 2000),
|
|
44
|
+
"success_criteria": _f("text", 2000, True), "method": _f("str", 200), "budget": _f("dict", 400),
|
|
45
|
+
"power_check": _f("text", 1000), "cost_minutes": _f("num"), "seed": _f("any", 200)}},
|
|
46
|
+
"run": {"prefix": "RUN", "fields": {
|
|
47
|
+
"kind": _f("enum:lab|agent|external", req=True), "job_id": _f("str", 80), "name": _f("str", 300),
|
|
48
|
+
"state": _f("enum:queued|running|succeeded|failed|timeout|cancelled|interrupted|unknown"),
|
|
49
|
+
"code": _f("list", 50), "inputs": _f("list", 200), "outputs": _f("list", 500), "args": _f("list", 100),
|
|
50
|
+
"config": _f("dict", 20000), "seed": _f("any", 200), "env": _f("dict", 4000), "started": _f("str", 40),
|
|
51
|
+
"finished": _f("str", 40), "exit_code": _f("any", 20), "log_sha256": _f("str", 80), "note": _f("text", 2000)}},
|
|
52
|
+
"result": {"prefix": "RS", "fields": {
|
|
53
|
+
"summary": _f("text", 2000, True), "kind": _f("enum:computed|observed|cited"),
|
|
54
|
+
"outcome": _f("enum:positive|negative|inconclusive|expected_failure|skipped|failed"),
|
|
55
|
+
"values": _f("dict", 20000), "expected": _f("text", 1500), "limitations": _f("text", 1500),
|
|
56
|
+
"power": _f("enum:demonstrated|not_demonstrated|not_applicable"), "evidence_note": _f("text", 1500),
|
|
57
|
+
"legacy": _f("dict", 4000)}},
|
|
58
|
+
"artifact": {"prefix": "A", "fields": {
|
|
59
|
+
"sha256": _f("str", 64, True), "path": _f("str", 500), "size": _f("num"), "format": _f("str", 40)}},
|
|
60
|
+
"source": {"prefix": "S", "fields": {
|
|
61
|
+
"citation": _f("text", 1000, True), "doi": _f("str", 200), "url": _f("str", 500), "passage": _f("text", 3000),
|
|
62
|
+
"retrieved": _f("str", 40), "says": _f("text", 1500)}},
|
|
63
|
+
"review": {"prefix": "RV", "fields": {
|
|
64
|
+
"verdict": _f("enum:" + "|".join(sorted(VERDICTS)), req=True), "method": _f("text", 1500, True),
|
|
65
|
+
"notes": _f("text", 4000, True), "official": _f("bool")}},
|
|
66
|
+
"decision": {"prefix": "D", "fields": {
|
|
67
|
+
"choice": _f("text", 1500, True), "alternatives": _f("list", 20), "reasons": _f("text", 3000, True),
|
|
68
|
+
"revisit_if": _f("text", 1500), "meeting": _f("str", 80)}},
|
|
69
|
+
"task": {"prefix": "T", "fields": {"kanban_id": _f("str", 80, True), "title": _f("str", 300)}},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ANY = set(TYPES)
|
|
73
|
+
EVIDENCE_SRC = {"result", "claim", "source"}
|
|
74
|
+
RELS: dict[str, tuple[set, set]] = {
|
|
75
|
+
"part_of": (ANY - {"investigation"}, {"investigation", "question"}),
|
|
76
|
+
"answers": ({"hypothesis", "claim"}, {"question"}),
|
|
77
|
+
"alternative_to": ({"hypothesis"}, {"hypothesis"}),
|
|
78
|
+
"tests": ({"experiment"}, {"hypothesis", "claim"}),
|
|
79
|
+
"executes": ({"run"}, {"experiment"}),
|
|
80
|
+
"produced": ({"run"}, {"result", "artifact"}),
|
|
81
|
+
"uses": ({"run"}, {"artifact"}),
|
|
82
|
+
"derived_from": ({"result", "claim", "artifact"}, {"result", "artifact", "run", "source"}),
|
|
83
|
+
"supports": (EVIDENCE_SRC, {"claim", "hypothesis"}),
|
|
84
|
+
"challenges": (EVIDENCE_SRC, {"claim", "hypothesis"}),
|
|
85
|
+
"requires": ({"claim", "hypothesis"}, {"claim"}),
|
|
86
|
+
"replicates": ({"run"}, {"run"}),
|
|
87
|
+
"reviews": ({"review"}, {"claim", "result", "run", "experiment", "hypothesis"}),
|
|
88
|
+
"supersedes": (ANY - {"artifact", "review", "investigation"}, ANY - {"artifact", "review", "investigation"}),
|
|
89
|
+
"corrects": ({"claim", "result", "decision"}, {"claim", "result", "decision"}),
|
|
90
|
+
"motivated_by": ({"experiment", "hypothesis", "question"}, ANY),
|
|
91
|
+
"cites": ({"claim", "result", "hypothesis", "experiment"}, {"source"}),
|
|
92
|
+
"based_on": ({"decision"}, {"claim", "result", "hypothesis"}),
|
|
93
|
+
"works_on": ({"task"}, ANY - {"task", "artifact", "review"}),
|
|
94
|
+
"blocked_by": ({"task"}, {"task"}),
|
|
95
|
+
}
|
|
96
|
+
REPLICATION = ("same_code", "new_seed", "new_impl", "new_data", "new_env")
|
|
97
|
+
RISKY = ("requires", "derived_from", "based_on")
|
|
98
|
+
SAME_TYPE = ("supersedes", "corrects")
|
|
99
|
+
|
|
100
|
+
# One line each — what the type is for. Shown by `hypgraph schema`, the MCP server and the site.
|
|
101
|
+
ABOUT = {
|
|
102
|
+
"investigation": "The study: an objective, its scope, who reviews it. Every other node belongs to one.",
|
|
103
|
+
"question": "Something you want to know.",
|
|
104
|
+
"hypothesis": "A possible answer, with the predictions that would show it wrong.",
|
|
105
|
+
"claim": "What you assert is known, with its scope and its numbers bound to results.",
|
|
106
|
+
"experiment": "A planned test: aim, protocol, controls, success criteria, and what each hypothesis expects.",
|
|
107
|
+
"run": "One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument.",
|
|
108
|
+
"result": "What a run (or an observation, or a citation) found: values, outcome, power.",
|
|
109
|
+
"artifact": "Exact bytes, by sha256 — data, code, a model, a figure.",
|
|
110
|
+
"source": "Outside literature or data: citation, DOI, the passage that says it.",
|
|
111
|
+
"review": "A verdict on one version of a claim, result, run, experiment or hypothesis.",
|
|
112
|
+
"decision": "A choice made on the evidence; flagged when what it was based on changes.",
|
|
113
|
+
"task": "A unit of work in your tracker, linked to what it works on.",
|
|
114
|
+
}
|
|
115
|
+
REL_ABOUT = {
|
|
116
|
+
"part_of": "belongs to an investigation or question", "answers": "offers an answer to a question",
|
|
117
|
+
"alternative_to": "competes with another hypothesis", "tests": "an experiment tests a hypothesis or claim "
|
|
118
|
+
"(data.expected: what it should show if true)", "executes": "a run carries out an experiment",
|
|
119
|
+
"produced": "a run produced a result or artifact", "uses": "a run used an artifact (data.role: data / code / "
|
|
120
|
+
"model / config)", "derived_from": "computed or taken from", "supports": "evidence for (why required)",
|
|
121
|
+
"challenges": "evidence against (why required)", "requires": "rests on another claim as a premise",
|
|
122
|
+
"replicates": "a run repeats another (data.differs: same_code / new_seed / new_impl / new_data / new_env)",
|
|
123
|
+
"reviews": "a review of a node (written by review())", "supersedes": "replaces an older node of the same type",
|
|
124
|
+
"corrects": "fixes an error in a node of the same type", "motivated_by": "prompted by",
|
|
125
|
+
"cites": "cites a source", "based_on": "a decision rests on this evidence (pinned to its version)",
|
|
126
|
+
"works_on": "a task works on a node", "blocked_by": "a task waits on another task",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def describe() -> dict:
|
|
131
|
+
"""The whole vocabulary as JSON-ready data."""
|
|
132
|
+
return {
|
|
133
|
+
"format": FORMAT,
|
|
134
|
+
"types": {t: {"prefix": v["prefix"], "about": ABOUT[t],
|
|
135
|
+
"fields": {k: {"kind": f[0], "max": f[1], "required": f[2]} for k, f in v["fields"].items()}}
|
|
136
|
+
for t, v in TYPES.items()},
|
|
137
|
+
"relations": {r: {"from": sorted(a), "to": sorted(b), "about": REL_ABOUT[r]} for r, (a, b) in RELS.items()},
|
|
138
|
+
"verdicts": {"positive": sorted(POSITIVE), "negative": sorted(NEGATIVE),
|
|
139
|
+
"other": sorted(VERDICTS - POSITIVE - NEGATIVE)},
|
|
140
|
+
"evidence_rank": PROFILE_RANK, "evidence_label": PROFILE_LABEL, "replication": list(REPLICATION),
|
|
141
|
+
}
|