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/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
+ }