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/mcp.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``hypgraph mcp`` — the research graph as an MCP server, for agents (needs ``pip install "hypgraph[mcp]"``).
|
|
3
|
+
|
|
4
|
+
claude mcp add hypgraph -- hypgraph --db study.hypgraph --as claude mcp
|
|
5
|
+
|
|
6
|
+
The server writes as ONE author, fixed when it starts (``--as``, else $HYPGRAPH_AUTHOR, else "agent"): an agent can't
|
|
7
|
+
pass someone else's name, can't write as the instrument or the operator, and — unless the investigation lists it as
|
|
8
|
+
a reviewer — its verdicts are comments. Claims it writes are checked like anyone's: every number bound to a result,
|
|
9
|
+
no absolutes without k of n, exclusions need a powered search. A refusal comes back as a tool error that says what to
|
|
10
|
+
fix, which is the point.
|
|
11
|
+
|
|
12
|
+
Tools: overview · find · show · start_investigation · add · link · revise · withdraw · retract_link · review ·
|
|
13
|
+
next_experiments · work_queue · claim_work · register_run · report · verify · schema.
|
|
14
|
+
Resources: hypgraph://skill · hypgraph://schema · hypgraph://investigations · hypgraph://investigation/{inv}/report.
|
|
15
|
+
Prompt: method — how to work a question through the record.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .graph import Graph, GraphError, file_sha256
|
|
28
|
+
from .schema import TYPES, describe
|
|
29
|
+
|
|
30
|
+
INSTRUCTIONS = """HypGraph is a research record that can't flatter itself. Work a question through it:
|
|
31
|
+
1. overview() — read what is already known before re-deriving anything.
|
|
32
|
+
2. add hypotheses with predictions; add experiments with `tests` links whose data.expected says what each hypothesis
|
|
33
|
+
predicts BEFORE you run anything (that is what makes it a test).
|
|
34
|
+
3. register_run() for computations you ran (it hashes the files); add results with values {name: {value} | {k, n}}.
|
|
35
|
+
4. add a claim only with its evidence: a `supports` link from a result (with why), a scope, and every number in the
|
|
36
|
+
statement bound to a result field in `measurements`. Absolutes ("never", "all") need k of n with k = 0 or n.
|
|
37
|
+
An exclusion needs a search shown to recover a planted answer (result power=demonstrated).
|
|
38
|
+
5. You can't set standing. Reviews by the investigation's reviewers do; nobody reviews their own work.
|
|
39
|
+
When the record refuses a write, it says what to fix — fix that, don't route around it."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _line(n: dict, width: int = 110) -> str:
|
|
43
|
+
t = " ".join(str(n.get("title") or "").split())
|
|
44
|
+
t = t if len(t) <= width else t[:width - 1] + "…"
|
|
45
|
+
return f"{n['code'] or n['id']} [{n['type']}] {t}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def overview_text(g: Graph, inv: str, reviewer: str = "", limit: int = 3200) -> str:
|
|
49
|
+
"""The record around an investigation in ~3,000 characters: objective, live hypotheses, what's established and
|
|
50
|
+
how, what's shaky, who's on what, reviews waiting, next experiments."""
|
|
51
|
+
s_inv = g.get(inv)
|
|
52
|
+
b = s_inv["body"]
|
|
53
|
+
sub = g.subgraph(inv, types=["hypothesis", "claim"])
|
|
54
|
+
work, nxt = g.work_map(inv), g.next_experiments(inv, 4)
|
|
55
|
+
state = b.get("state") or "active"
|
|
56
|
+
L = [f"## {b.get('title', inv)} ({s_inv['code']}, {inv}, {state})",
|
|
57
|
+
f"Objective: {' '.join(str(b.get('objective', '')).split())[:300]}"]
|
|
58
|
+
if b.get("reviewers"):
|
|
59
|
+
L.append("Reviewers (their verdicts count): " + ", ".join(b["reviewers"]))
|
|
60
|
+
if state != "active":
|
|
61
|
+
L.append(f"This investigation is {state}: its record is fixed. Review it, and build on it in a follow-up.")
|
|
62
|
+
nodes = sub["nodes"]
|
|
63
|
+
live = lambda n: n["state"].get("lifecycle") == "active" # noqa: E731
|
|
64
|
+
hyps = [n for n in nodes if n["type"] == "hypothesis" and live(n) and not n["state"].get("fallen")]
|
|
65
|
+
if hyps:
|
|
66
|
+
L.append(f"Live hypotheses ({len(hyps)}):")
|
|
67
|
+
L += [f" {_line(n)} — {n['state']['sentence'][:150]}" for n in hyps[:6]]
|
|
68
|
+
claims = [n for n in nodes if n["type"] == "claim" and live(n)]
|
|
69
|
+
checked = [n for n in claims if n["state"].get("review", {}).get("state") == "checked"]
|
|
70
|
+
if checked:
|
|
71
|
+
L.append(f"Established — checked claims ({len(checked)}):")
|
|
72
|
+
L += [f" {_line(n, 120)} — {(n['state'].get('evidence') or '').replace('_', ' ')}, "
|
|
73
|
+
f"{n['state'].get('independent_lines', 0)} line(s)" for n in checked[:8]]
|
|
74
|
+
open_claims = [n for n in claims if n not in checked]
|
|
75
|
+
if open_claims:
|
|
76
|
+
L.append("Other live claims: " + "; ".join(f"{n['code']} ({n['state']['review']['state']})"
|
|
77
|
+
for n in open_claims[:12]))
|
|
78
|
+
shaky = [n for n in claims if n["state"].get("review", {}).get("state") == "contested" or n["state"].get("at_risk")]
|
|
79
|
+
if shaky:
|
|
80
|
+
L.append("Contested or AT RISK — don't cite without checking: " + ", ".join(n["code"] for n in shaky[:20]))
|
|
81
|
+
if work["leases"]:
|
|
82
|
+
L.append("Being worked: " + "; ".join(f"{x['agent']} → {x['node']['code']}" for x in work["leases"][:8]))
|
|
83
|
+
if reviewer and reviewer in set(b.get("reviewers") or []) | set(g.policy.reviewers) and work["review_queue"]:
|
|
84
|
+
L.append(f"Reviews waiting for you ({len(work['review_queue'])}): "
|
|
85
|
+
+ ", ".join(n["code"] for n in work["review_queue"][:10]))
|
|
86
|
+
if work["revalidation"]:
|
|
87
|
+
L.append("Needs revalidation (something it rests on moved): "
|
|
88
|
+
+ ", ".join(n["code"] for n in work["revalidation"][:10]))
|
|
89
|
+
if work["decisions_to_revisit"]:
|
|
90
|
+
L.append("Decisions to revisit (their basis changed): "
|
|
91
|
+
+ ", ".join(n["code"] for n in work["decisions_to_revisit"][:10]))
|
|
92
|
+
if nxt["ranked"] and state == "active":
|
|
93
|
+
L.append("Next experiments: " + "; ".join(
|
|
94
|
+
f"{r['experiment']['code']} (separates {r['separates']}{', powered' if r['power_check'] else ''}"
|
|
95
|
+
f"{', DUPLICATE' if r['duplicate'] else ''})" for r in nxt["ranked"][:4]))
|
|
96
|
+
if nxt["untested_hypotheses"] and state == "active":
|
|
97
|
+
L.append("Hypotheses no experiment tests yet: " + ", ".join(h["code"] for h in nxt["untested_hypotheses"]))
|
|
98
|
+
text = "\n".join(L)
|
|
99
|
+
return text if len(text) <= limit else text[:limit - 30] + "\n … (find / show for more)"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def show_text(n: dict) -> str:
|
|
103
|
+
st = n.get("state") or {}
|
|
104
|
+
out = [f"{n['code']} ({n['id']}) — {n['type']} v{n['v']} by {n['by']} {n['at'][:10]}",
|
|
105
|
+
f"standing: {st.get('sentence', '')}"]
|
|
106
|
+
for k, v in (n.get("body") or {}).items():
|
|
107
|
+
if k != "legacy":
|
|
108
|
+
out.append(f" {k}: {v[:700] if isinstance(v, str) else json.dumps(v, ensure_ascii=False)[:700]}")
|
|
109
|
+
if n.get("withdrawn"):
|
|
110
|
+
out.append(f" WITHDRAWN by {n['withdrawn']['by']}: {n['withdrawn']['why']}")
|
|
111
|
+
outs = [f" this -{e['rel']}-> {e['dst']}" + (f" ({e['why'][:90]})" if e.get("why") else "")
|
|
112
|
+
for e in n.get("out", [])]
|
|
113
|
+
ins = [f" {e['src']} -{e['rel']}-> this" + (f" ({e['why'][:90]})" if e.get("why") else "")
|
|
114
|
+
for e in n.get("in", [])]
|
|
115
|
+
if outs:
|
|
116
|
+
out += [" links out:"] + outs[:25]
|
|
117
|
+
if ins:
|
|
118
|
+
out += [" links in:"] + ins[:25]
|
|
119
|
+
return "\n".join(out)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build(graph: Graph | str, author: Optional[str] = None, files_root: str | None = None):
|
|
123
|
+
"""The FastMCP server over one graph, writing as one author."""
|
|
124
|
+
try:
|
|
125
|
+
from fastmcp import FastMCP
|
|
126
|
+
from fastmcp.exceptions import ToolError
|
|
127
|
+
except ImportError as e: # pragma: no cover
|
|
128
|
+
raise SystemExit('the MCP server needs FastMCP: pip install "hypgraph[mcp]"') from e
|
|
129
|
+
g = graph if isinstance(graph, Graph) else Graph(graph)
|
|
130
|
+
me = (author or os.getenv("HYPGRAPH_AUTHOR") or "agent").strip().lower()[:40]
|
|
131
|
+
if me in g.policy.reserved:
|
|
132
|
+
raise SystemExit(f"{me!r} is a reserved author ({', '.join(sorted(g.policy.reserved))}); pick another --as")
|
|
133
|
+
root = Path(files_root or os.getcwd()).resolve()
|
|
134
|
+
mcp = FastMCP("hypgraph", instructions=INSTRUCTIONS + f"\nYou write as {me!r}.", version=__version__,
|
|
135
|
+
website_url="https://hypgraph.com")
|
|
136
|
+
|
|
137
|
+
def fail(e: Exception):
|
|
138
|
+
raise ToolError(str(e) if not isinstance(e, KeyError) else f"not found: {e.args[0] if e.args else e}")
|
|
139
|
+
|
|
140
|
+
def inv_of(ref: str) -> str:
|
|
141
|
+
if ref:
|
|
142
|
+
return g.resolve(ref)
|
|
143
|
+
invs = [i for i in g.investigations() if i["state"]["lifecycle"] == "active"] or g.investigations()
|
|
144
|
+
if len(invs) == 1:
|
|
145
|
+
return invs[0]["id"]
|
|
146
|
+
if not invs:
|
|
147
|
+
raise GraphError("no investigation yet — start_investigation(title, objective)")
|
|
148
|
+
raise GraphError("which investigation? " + "; ".join(f"{i['code']} {i['id']} — {i['title'][:50]}" for i in invs))
|
|
149
|
+
|
|
150
|
+
def resolve_links(links: list[dict] | None, inv: str) -> list[dict]:
|
|
151
|
+
out = []
|
|
152
|
+
for raw in links or []:
|
|
153
|
+
ln = dict(raw)
|
|
154
|
+
for k in ("to", "from"):
|
|
155
|
+
if ln.get(k):
|
|
156
|
+
ln[k] = g.resolve(str(ln[k]), inv)
|
|
157
|
+
out.append(ln)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
@mcp.tool
|
|
161
|
+
def overview(investigation: str = "") -> str:
|
|
162
|
+
"""Read this first. The record around an investigation: objective, reviewers, live hypotheses, established
|
|
163
|
+
claims (and how they're known), contested and at-risk work, who's on what, reviews and revalidations
|
|
164
|
+
waiting, next experiments. With several investigations and none named, lists them."""
|
|
165
|
+
try:
|
|
166
|
+
if not investigation:
|
|
167
|
+
invs = g.investigations()
|
|
168
|
+
if len(invs) != 1:
|
|
169
|
+
return "\n".join(f"{i['code']} {i['id']} — {i['title']} ({i['state']['lifecycle']}, "
|
|
170
|
+
f"{sum(i['counts'].values())} nodes)" for i in invs) or \
|
|
171
|
+
"No investigations yet — start_investigation(title, objective, reviewers)."
|
|
172
|
+
return overview_text(g, inv_of(investigation), reviewer=me)
|
|
173
|
+
except (GraphError, KeyError) as e:
|
|
174
|
+
fail(e)
|
|
175
|
+
|
|
176
|
+
@mcp.tool
|
|
177
|
+
def find(text: str = "", type: str = "", investigation: str = "", limit: int = 20) -> str:
|
|
178
|
+
"""Search the record by words or code (H1, CL3 …). type filters: investigation, question, hypothesis, claim,
|
|
179
|
+
experiment, run, result, artifact, source, review, decision, task."""
|
|
180
|
+
try:
|
|
181
|
+
rows = g.search(text=text, type=type, inv=g.resolve(investigation) if investigation else "",
|
|
182
|
+
limit=max(1, min(int(limit), 100)))
|
|
183
|
+
except (GraphError, KeyError) as e:
|
|
184
|
+
fail(e)
|
|
185
|
+
return "\n".join(f"{_line(n)} · {n['id']} · {n['state']['sentence'][:150]}" for n in rows) or "Nothing found."
|
|
186
|
+
|
|
187
|
+
@mcp.tool
|
|
188
|
+
def show(node: str, view: str = "summary", investigation: str = "") -> str:
|
|
189
|
+
"""One node by id or code. view: summary (fields, standing, links) | why (what it rests on, down to runs, data
|
|
190
|
+
and sources) | impact (what rests on it — what breaks if it's wrong) | history (its versions)."""
|
|
191
|
+
try:
|
|
192
|
+
nid = g.resolve(node, g.resolve(investigation) if investigation else "")
|
|
193
|
+
if view == "why":
|
|
194
|
+
r = g.why(nid)
|
|
195
|
+
return f"WHY {node} — what it rests on:\n" + "\n".join(
|
|
196
|
+
f" {_line(n)} — {n['state']['sentence'][:150]}" for n in r["nodes"] if n["id"] != nid) \
|
|
197
|
+
+ "\n edges:\n" + "\n".join(f" {e['src']} -{e['rel']}-> {e['dst']}" for e in r["edges"][:60])
|
|
198
|
+
if view == "impact":
|
|
199
|
+
r = g.impact(nid)
|
|
200
|
+
return (f"IF {node} IS WRONG — {len(r['dependents'])} node(s) rest on it:\n" + "\n".join(
|
|
201
|
+
f" {' ' * (d['depth'] - 1)}{d['rel']} ← {_line(d['node'])}" for d in r["dependents"][:40])) \
|
|
202
|
+
if r["dependents"] else f"Nothing rests on {node}."
|
|
203
|
+
if view == "history":
|
|
204
|
+
n = g.get(nid, history=True)
|
|
205
|
+
return show_text(n) + "\n versions:\n" + "\n".join(
|
|
206
|
+
f" v{v['v']} {v['at'][:10]} {v['by']}: {v['reason'] or '(created)'}" for v in n["versions"])
|
|
207
|
+
return show_text(g.get(nid))
|
|
208
|
+
except (GraphError, KeyError) as e:
|
|
209
|
+
fail(e)
|
|
210
|
+
|
|
211
|
+
@mcp.tool
|
|
212
|
+
def start_investigation(title: str, objective: str, reviewers: list[str] | None = None, scope: str = "",
|
|
213
|
+
success_criteria: str = "") -> str:
|
|
214
|
+
"""Open an investigation: what you want to find out (objective, a sentence), its scope, what would count as
|
|
215
|
+
an answer, and who reviews it (their verdicts set the standing; you can't review your own work)."""
|
|
216
|
+
try:
|
|
217
|
+
n = g.create(author=me, type="investigation", fields={
|
|
218
|
+
"title": title, "objective": objective, "scope": scope, "success_criteria": success_criteria,
|
|
219
|
+
"reviewers": [r.strip().lower() for r in reviewers or [] if r.strip()]})
|
|
220
|
+
except GraphError as e:
|
|
221
|
+
fail(e)
|
|
222
|
+
return f"Opened {n['code']} ({n['id']}). Next: add questions and hypotheses (with predictions)."
|
|
223
|
+
|
|
224
|
+
@mcp.tool
|
|
225
|
+
def add(type: str, fields: dict[str, Any], investigation: str = "", links: list[dict[str, Any]] | None = None) -> str:
|
|
226
|
+
"""Add a node. type: question {text} · hypothesis {statement, predictions[]} · experiment {aim, protocol,
|
|
227
|
+
success_criteria, controls, method, power_check, cost_minutes} with `tests` links carrying data.expected per
|
|
228
|
+
hypothesis · run {kind: agent, name, state} (prefer register_run) · result {summary, values {name: {value} |
|
|
229
|
+
{k, n}}, outcome, power} · claim {statement, scope, kind finding|exclusion|inconclusive|convention|lead,
|
|
230
|
+
measurements [{name, value | k, n, result: <id>, key: <values field>}]} with a `supports` link from its
|
|
231
|
+
result · source {citation, doi, url, passage} · decision {choice, reasons, alternatives} with `based_on`.
|
|
232
|
+
links: [{"rel": …, "to": id-or-code} (from this node) or {"rel": …, "from": id-or-code, "why": "a sentence"}
|
|
233
|
+
(into it), optional "data": {...}]."""
|
|
234
|
+
try:
|
|
235
|
+
if type not in TYPES or type in ("investigation", "review"):
|
|
236
|
+
raise GraphError("type: " + ", ".join(t for t in TYPES if t not in ("investigation", "review"))
|
|
237
|
+
+ " (start_investigation / review for the others)")
|
|
238
|
+
inv = inv_of(investigation)
|
|
239
|
+
n = g.create(author=me, type=type, fields=fields, inv=inv, links=resolve_links(links, inv))
|
|
240
|
+
except (GraphError, KeyError) as e:
|
|
241
|
+
fail(e)
|
|
242
|
+
notes = "".join(f" Note: {x}." for x in n.get("notes") or [])
|
|
243
|
+
return f"Added {n['code']} ({n['id']}) — {n['state']['sentence']}.{notes}"
|
|
244
|
+
|
|
245
|
+
@mcp.tool
|
|
246
|
+
def link(src: str, rel: str, dst: str, why: str = "", data: dict[str, Any] | None = None,
|
|
247
|
+
investigation: str = "") -> str:
|
|
248
|
+
"""Connect two nodes. rel: supports | challenges (why required) | requires | tests (data.expected) | executes
|
|
249
|
+
| produced | uses (data.role) | derived_from | replicates (data.differs: same_code | new_seed | new_impl |
|
|
250
|
+
new_data | new_env) | supersedes | corrects | answers | part_of | alternative_to | motivated_by | cites |
|
|
251
|
+
based_on. A link given backwards is turned round when only the reverse is allowed."""
|
|
252
|
+
try:
|
|
253
|
+
inv = g.resolve(investigation) if investigation else ""
|
|
254
|
+
r = g.link(author=me, src=g.resolve(src, inv), rel=rel.strip().lower(), dst=g.resolve(dst, inv), why=why,
|
|
255
|
+
data=data or {})
|
|
256
|
+
except (GraphError, KeyError) as e:
|
|
257
|
+
fail(e)
|
|
258
|
+
return f"Linked {r['src']} -{r['rel']}-> {r['dst']} (edge {r['edge']})" + \
|
|
259
|
+
(" — turned round to the direction the relation allows." if r["flipped"] else ".")
|
|
260
|
+
|
|
261
|
+
@mcp.tool
|
|
262
|
+
def revise(node: str, fields: dict[str, Any], reason: str, investigation: str = "") -> str:
|
|
263
|
+
"""A new version of a node you wrote (or review): the given fields replace the old ones. Reviews of the old
|
|
264
|
+
version stay on record but go stale; decisions based on it are flagged to revisit."""
|
|
265
|
+
try:
|
|
266
|
+
n = g.revise(author=me, node=g.resolve(node, g.resolve(investigation) if investigation else ""),
|
|
267
|
+
fields=fields, reason=reason)
|
|
268
|
+
except (GraphError, KeyError) as e:
|
|
269
|
+
fail(e)
|
|
270
|
+
return f"{n['code']} is now v{n['v']} — {n['state']['sentence']}"
|
|
271
|
+
|
|
272
|
+
@mcp.tool
|
|
273
|
+
def withdraw(node: str, why: str, investigation: str = "") -> str:
|
|
274
|
+
"""Withdraw a node you wrote. It stays on record, marked withdrawn; whatever rested on it is flagged AT RISK."""
|
|
275
|
+
try:
|
|
276
|
+
n = g.withdraw(author=me, node=g.resolve(node, g.resolve(investigation) if investigation else ""), why=why)
|
|
277
|
+
except (GraphError, KeyError) as e:
|
|
278
|
+
fail(e)
|
|
279
|
+
return f"{n['code']} — {n['state']['sentence']}"
|
|
280
|
+
|
|
281
|
+
@mcp.tool
|
|
282
|
+
def retract_link(edge: int, why: str) -> str:
|
|
283
|
+
"""Retract a link you made (by edge id), saying why it no longer holds. The retraction is on record."""
|
|
284
|
+
try:
|
|
285
|
+
g.retract(author=me, edge=int(edge), why=why)
|
|
286
|
+
except GraphError as e:
|
|
287
|
+
fail(e)
|
|
288
|
+
return f"Retracted edge {edge}."
|
|
289
|
+
|
|
290
|
+
@mcp.tool
|
|
291
|
+
def review(node: str, verdict: str, method: str, notes: str, investigation: str = "") -> str:
|
|
292
|
+
"""Review a node's current version: checked | pass | reproduced | replicated | separate_implementation |
|
|
293
|
+
generalizes | fail | not_reproduced | not_replicated | does_not_generalize | objection | resolved |
|
|
294
|
+
inconclusive | comment. Say how you checked (method) and what you found (notes). Only the investigation's
|
|
295
|
+
reviewers' verdicts count; anyone else's are kept as comments. Not your own work."""
|
|
296
|
+
try:
|
|
297
|
+
n = g.review(author=me, target=g.resolve(node, g.resolve(investigation) if investigation else ""),
|
|
298
|
+
verdict=verdict.strip().lower(), method=method, notes=notes)
|
|
299
|
+
except (GraphError, KeyError) as e:
|
|
300
|
+
fail(e)
|
|
301
|
+
return f"Recorded on {n['code']}: {n['state']['sentence']}"
|
|
302
|
+
|
|
303
|
+
@mcp.tool
|
|
304
|
+
def next_experiments(investigation: str = "") -> str:
|
|
305
|
+
"""Planned experiments ranked by how well they separate the live hypotheses (distinct expected outcomes),
|
|
306
|
+
whether they can show their own power, and cost; duplicates and work someone holds flagged; untested
|
|
307
|
+
hypotheses listed."""
|
|
308
|
+
try:
|
|
309
|
+
r = g.next_experiments(inv_of(investigation))
|
|
310
|
+
except (GraphError, KeyError) as e:
|
|
311
|
+
fail(e)
|
|
312
|
+
out = [f"Next experiments ({r['rule']}):"]
|
|
313
|
+
out += [f" {x['experiment']['code']} score {x['score']} — separates {x['separates']}, "
|
|
314
|
+
f"{'power check' if x['power_check'] else 'no power check'}, ~{int(x['cost_minutes'])} min"
|
|
315
|
+
+ (" — DUPLICATE" if x["duplicate"] else "") + (f" — {x['lease']['agent']} on it" if x["lease"] else "")
|
|
316
|
+
+ f": {x['experiment']['title'][:100]}" for x in r["ranked"]] or [" (none planned)"]
|
|
317
|
+
if r["untested_hypotheses"]:
|
|
318
|
+
out.append("Untested hypotheses: " + ", ".join(_line(h, 80) for h in r["untested_hypotheses"]))
|
|
319
|
+
return "\n".join(out)
|
|
320
|
+
|
|
321
|
+
@mcp.tool
|
|
322
|
+
def work_queue(investigation: str = "") -> str:
|
|
323
|
+
"""What needs doing: leases (who's on what), the review queue, revalidations (something a node rests on
|
|
324
|
+
moved), contested work, decisions to revisit, duplicate experiments."""
|
|
325
|
+
try:
|
|
326
|
+
w = g.work_map(inv_of(investigation))
|
|
327
|
+
except (GraphError, KeyError) as e:
|
|
328
|
+
fail(e)
|
|
329
|
+
sec = lambda name, rows: f"{name} ({len(rows)}): " + (", ".join( # noqa: E731
|
|
330
|
+
f"{x['code']} {x['title'][:50]}" for x in rows[:12]) or "—")
|
|
331
|
+
return "\n".join(["Being worked: " + (", ".join(f"{x['agent']} → {x['node']['code']}" for x in w["leases"])
|
|
332
|
+
or "—"),
|
|
333
|
+
sec("Review queue", w["review_queue"]), sec("Needs revalidation", w["revalidation"]),
|
|
334
|
+
sec("Contested", w["contested"]), sec("Decisions to revisit", w["decisions_to_revisit"]),
|
|
335
|
+
f"Duplicate experiment groups: {len(w['duplicates'])}"])
|
|
336
|
+
|
|
337
|
+
@mcp.tool
|
|
338
|
+
def claim_work(node: str, release: bool = False, minutes: int = 240, investigation: str = "") -> str:
|
|
339
|
+
"""Take a node (an experiment to run, a claim to review) so others see you're on it — or release it."""
|
|
340
|
+
try:
|
|
341
|
+
n = g.lease(author=me, node=g.resolve(node, g.resolve(investigation) if investigation else ""),
|
|
342
|
+
minutes=int(minutes), release=bool(release))
|
|
343
|
+
except (GraphError, KeyError) as e:
|
|
344
|
+
fail(e)
|
|
345
|
+
return f"{'Released' if release else 'Took'} {n['code']} — {n['state']['sentence']}"
|
|
346
|
+
|
|
347
|
+
@mcp.tool
|
|
348
|
+
def register_run(name: str, investigation: str = "", code_paths: list[str] | None = None,
|
|
349
|
+
input_paths: list[str] | None = None, output_paths: list[str] | None = None, command: str = "",
|
|
350
|
+
seed: str = "", experiment: str = "", result_summary: str = "",
|
|
351
|
+
result_values: dict[str, Any] | None = None, outcome: str = "", limitations: str = "") -> str:
|
|
352
|
+
"""Record a computation you ran. The server hashes the files itself (code, inputs, outputs — paths under its
|
|
353
|
+
files root), records the run as agent-reported, and adds the result it produced, ready for claims to bind
|
|
354
|
+
to: result_values {name: {value, unit} | {k, n}}."""
|
|
355
|
+
try:
|
|
356
|
+
inv = inv_of(investigation)
|
|
357
|
+
|
|
358
|
+
def hashed(paths):
|
|
359
|
+
out = []
|
|
360
|
+
for p in paths or []:
|
|
361
|
+
q = (root / p).resolve() if not Path(p).is_absolute() else Path(p).resolve()
|
|
362
|
+
if not q.is_relative_to(root):
|
|
363
|
+
raise GraphError(f"{p}: only files under {root} can be registered")
|
|
364
|
+
if not q.is_file():
|
|
365
|
+
raise GraphError(f"{p}: no such file")
|
|
366
|
+
out.append(file_sha256(q) | {"path": str(q.relative_to(root))})
|
|
367
|
+
return out
|
|
368
|
+
files = {"code": hashed(code_paths), "inputs": hashed(input_paths), "outputs": hashed(output_paths)}
|
|
369
|
+
if not files["code"] and not command.strip():
|
|
370
|
+
raise GraphError("say what ran: code_paths (hashed) and/or command")
|
|
371
|
+
links = []
|
|
372
|
+
for role, key in (("code", "code"), ("data", "inputs")):
|
|
373
|
+
for f in files[key]:
|
|
374
|
+
a = g.create(author=me, type="artifact", inv=inv, fields={
|
|
375
|
+
"sha256": f["sha256"], "path": f["path"], "size": f["size"], "format": Path(f["path"]).suffix[1:]})
|
|
376
|
+
links.append({"rel": "uses", "to": a["id"], "data": {"role": role}})
|
|
377
|
+
for f in files["outputs"]:
|
|
378
|
+
a = g.create(author=me, type="artifact", inv=inv, fields={
|
|
379
|
+
"sha256": f["sha256"], "path": f["path"], "size": f["size"], "format": Path(f["path"]).suffix[1:]})
|
|
380
|
+
links.append({"rel": "produced", "to": a["id"]})
|
|
381
|
+
if experiment.strip():
|
|
382
|
+
links.append({"rel": "executes", "to": g.resolve(experiment, inv)})
|
|
383
|
+
fields: dict[str, Any] = {"kind": "agent", "name": name[:300], "state": "succeeded", **files,
|
|
384
|
+
"args": [command[:300]] if command.strip() else [],
|
|
385
|
+
"note": f"Registered by {me}; files hashed by the MCP server when registered."}
|
|
386
|
+
if seed.strip():
|
|
387
|
+
fields["seed"] = int(seed) if re.fullmatch(r"-?\d+", seed.strip()) else seed.strip()[:64]
|
|
388
|
+
run = g.create(author=me, type="run", inv=inv, fields=fields, links=links)
|
|
389
|
+
msg = f"Registered {run['code']} ({run['id']}): {sum(len(v) for v in files.values())} file(s) hashed."
|
|
390
|
+
if result_summary.strip():
|
|
391
|
+
rf: dict[str, Any] = {"summary": result_summary, "kind": "computed", "values": result_values or {}}
|
|
392
|
+
if outcome:
|
|
393
|
+
rf["outcome"] = outcome
|
|
394
|
+
if limitations:
|
|
395
|
+
rf["limitations"] = limitations
|
|
396
|
+
r = g.create(author=me, type="result", inv=inv, fields=rf, links=[{"rel": "produced", "from": run["id"]}])
|
|
397
|
+
msg += f" Result {r['code']} ({r['id']}) — bind claims to it with measurements."
|
|
398
|
+
return msg
|
|
399
|
+
except (GraphError, KeyError, OSError) as e:
|
|
400
|
+
fail(e)
|
|
401
|
+
|
|
402
|
+
@mcp.tool
|
|
403
|
+
def report(investigation: str = "") -> str:
|
|
404
|
+
"""The facts of an investigation generated from the record (markdown): every claim with its standing and
|
|
405
|
+
verdicts, every result and whether a claim carries it, hypotheses, experiments (preregistered or not), open
|
|
406
|
+
items. Start any summary from it; it can't restate a withdrawn claim or override a verdict."""
|
|
407
|
+
try:
|
|
408
|
+
return g.report(inv_of(investigation))["markdown"]
|
|
409
|
+
except (GraphError, KeyError) as e:
|
|
410
|
+
fail(e)
|
|
411
|
+
|
|
412
|
+
@mcp.tool
|
|
413
|
+
def verify() -> str:
|
|
414
|
+
"""Recompute the hash chain and check every stored version and link against the event that wrote it."""
|
|
415
|
+
v = g.verify()
|
|
416
|
+
return (f"Verified {v['checked']} events: chain intact, every row matches its event (head {v['head'][:16]}…)."
|
|
417
|
+
if v["ok"] else f"VERIFY FAILED: {v['problem']}")
|
|
418
|
+
|
|
419
|
+
@mcp.tool
|
|
420
|
+
def schema() -> dict:
|
|
421
|
+
"""The node types (fields, which are required), the relations (which types each may join) and the verdicts."""
|
|
422
|
+
return describe()
|
|
423
|
+
|
|
424
|
+
@mcp.resource("hypgraph://schema", mime_type="application/json")
|
|
425
|
+
def schema_resource() -> str:
|
|
426
|
+
"""The hypgraph/1 vocabulary."""
|
|
427
|
+
return json.dumps(describe(), indent=1)
|
|
428
|
+
|
|
429
|
+
@mcp.resource("hypgraph://investigations", mime_type="application/json")
|
|
430
|
+
def investigations_resource() -> str:
|
|
431
|
+
"""Every investigation with its counts."""
|
|
432
|
+
return json.dumps([{k: i[k] for k in ("id", "code", "title", "counts")} | {"state": i["state"]["lifecycle"]}
|
|
433
|
+
for i in g.investigations()], indent=1)
|
|
434
|
+
|
|
435
|
+
@mcp.resource("hypgraph://investigation/{inv}/report", mime_type="text/markdown")
|
|
436
|
+
def report_resource(inv: str) -> str:
|
|
437
|
+
"""The facts of one investigation, generated from the record."""
|
|
438
|
+
return g.report(g.resolve(inv))["markdown"]
|
|
439
|
+
|
|
440
|
+
@mcp.resource("hypgraph://skill", mime_type="text/markdown")
|
|
441
|
+
def skill_resource() -> str:
|
|
442
|
+
"""The HypGraph agent skill (SKILL.md): the method, the refusals and their fixes."""
|
|
443
|
+
return (Path(__file__).parent / "skill" / "SKILL.md").read_text(encoding="utf-8")
|
|
444
|
+
|
|
445
|
+
@mcp.prompt
|
|
446
|
+
def method(question: str = "") -> str:
|
|
447
|
+
"""How to work a question through the record."""
|
|
448
|
+
return (INSTRUCTIONS + "\n\n" + (f"The question: {question}\n" if question else "")
|
|
449
|
+
+ "Start with overview(); if no investigation fits, start_investigation with reviewers named.")
|
|
450
|
+
|
|
451
|
+
mcp.graph = g
|
|
452
|
+
mcp.author = me
|
|
453
|
+
return mcp
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def run(db: str, author: Optional[str] = None, transport: str = "stdio", port: int = 8766) -> None:
|
|
457
|
+
server = build(db, author)
|
|
458
|
+
if transport == "http":
|
|
459
|
+
server.run(transport="http", host="127.0.0.1", port=port)
|
|
460
|
+
else:
|
|
461
|
+
server.run(show_banner=False)
|