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/cli.py ADDED
@@ -0,0 +1,445 @@
1
+ """
2
+ hypgraph — the command line.
3
+
4
+ hypgraph demo build the Broad Street (1854) teaching investigation into ./research.hypgraph
5
+ hypgraph serve open the Atlas explorer on http://127.0.0.1:8765
6
+ hypgraph init "Plant growth" --objective "…" --reviewers ada,lin
7
+ hypgraph add hypothesis statement="Blue light grows cress faster." predictions='["taller after 7 days"]'
8
+ hypgraph add result summary="Blue trays averaged 41 mm, red 33 mm." values='{"blue_mm": {"value": 41}}'
9
+ hypgraph link RS1 supports H1 --why "The blue trays grew taller, as H1 predicts."
10
+ hypgraph show CL1 --why what it rests on (--impact: what rests on it, --history: versions)
11
+ hypgraph report the facts, generated from the record
12
+ hypgraph export -o study.json a bundle anyone can verify: hypgraph verify study.json
13
+ hypgraph mcp an MCP server for agents (pip install "hypgraph[mcp]")
14
+ hypgraph skill --install the agent skill, into .claude/skills/hypgraph/SKILL.md
15
+
16
+ The graph file is --db, else $HYPGRAPH_DB, else ./research.hypgraph. You write as --as, else $HYPGRAPH_AUTHOR, else
17
+ your login name. Nodes can be named by id or by code (H1, CL2, X3 …). Add --json to any command for machine output.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import getpass
24
+ import json
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ from . import __version__
30
+ from .bundle import verify_bundle
31
+ from .graph import Graph, GraphError
32
+ from .schema import TYPES, describe
33
+ from .store import verify_file
34
+
35
+
36
+ def _out(args, data, text: str | None = None) -> None:
37
+ if getattr(args, "json", False) or text is None:
38
+ print(json.dumps(data, indent=1, ensure_ascii=False, default=str))
39
+ else:
40
+ print(text)
41
+
42
+
43
+ def _graph(args) -> Graph:
44
+ return Graph(args.db)
45
+
46
+
47
+ def _author(args) -> str:
48
+ who = args.author or os.getenv("HYPGRAPH_AUTHOR") or getpass.getuser()
49
+ return who.strip().lower()
50
+
51
+
52
+ def _line(n: dict, width: int = 100) -> str:
53
+ t = " ".join(str(n.get("title") or "").split())
54
+ t = t if len(t) <= width else t[:width - 1] + "…"
55
+ return f"{n['code'] or n['id']:<6} {n['type']:<12} {t}"
56
+
57
+
58
+ def _inv(g: Graph, ref: str = "") -> str:
59
+ """The investigation: named, else the only active one."""
60
+ if ref:
61
+ return g.resolve(ref)
62
+ invs = [i for i in g.investigations() if i["state"]["lifecycle"] == "active"] or g.investigations()
63
+ if len(invs) == 1:
64
+ return invs[0]["id"]
65
+ if not invs:
66
+ raise GraphError("no investigation yet — hypgraph init \"Title\" --objective \"…\"")
67
+ raise GraphError("which investigation? --inv " + " | ".join(f"{i['code']} ({i['title'][:40]})" for i in invs))
68
+
69
+
70
+ def _fields(pairs: list[str]) -> dict:
71
+ """key=value pairs; a value that parses as JSON (lists, objects, numbers) is taken as JSON."""
72
+ out = {}
73
+ for p in pairs or []:
74
+ if "=" not in p:
75
+ raise GraphError(f"{p!r}: give fields as key=value")
76
+ k, v = p.split("=", 1)
77
+ try:
78
+ out[k.strip()] = json.loads(v) if v.strip()[:1] in "[{0123456789-tfn\"" else v
79
+ except json.JSONDecodeError:
80
+ out[k.strip()] = v
81
+ return out
82
+
83
+
84
+ def _links(g: Graph, specs: list[str], inv: str) -> list[dict]:
85
+ """--link rel:to:NODE[:why] (from the new node) or rel:from:NODE[:why] (into it)."""
86
+ out = []
87
+ for s in specs or []:
88
+ parts = s.split(":", 3)
89
+ if len(parts) < 3 or parts[1] not in ("to", "from"):
90
+ raise GraphError(f"--link {s!r}: rel:to:NODE[:why] or rel:from:NODE[:why]")
91
+ link = {"rel": parts[0], parts[1]: g.resolve(parts[2], inv)}
92
+ if len(parts) > 3:
93
+ link["why"] = parts[3]
94
+ out.append(link)
95
+ return out
96
+
97
+
98
+ def _show(n: dict) -> str:
99
+ st = n.get("state") or {}
100
+ out = [f"{n['code']} ({n['id']}) — {n['type']} v{n['v']} by {n['by']} {n['at'][:10]}",
101
+ f"standing: {st.get('sentence', '')}"]
102
+ for k, v in (n.get("body") or {}).items():
103
+ if k != "legacy":
104
+ out.append(f" {k}: {v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}")
105
+ if n.get("withdrawn"):
106
+ out.append(f" WITHDRAWN by {n['withdrawn']['by']}: {n['withdrawn']['why']}")
107
+ for e in n.get("out", []):
108
+ out.append(f" → {e['rel']} {e['dst']}" + (f" ({e['why']})" if e.get("why") else ""))
109
+ for e in n.get("in", []):
110
+ out.append(f" ← {e['rel']} {e['src']}" + (f" ({e['why']})" if e.get("why") else ""))
111
+ return "\n".join(out)
112
+
113
+
114
+ # ── commands ────────────────────────────────────────────────────────
115
+
116
+ def cmd_init(args) -> int:
117
+ g = _graph(args)
118
+ fields = {"title": args.title, "objective": args.objective, **_fields(args.fields)}
119
+ if args.reviewers:
120
+ fields["reviewers"] = [x.strip().lower() for x in args.reviewers.split(",") if x.strip()]
121
+ n = g.create(author=_author(args), type="investigation", fields=fields)
122
+ _out(args, n, f"{n['code']} ({n['id']}) in {g.path}\nNext: hypgraph add question text=\"…\" · hypgraph add "
123
+ "hypothesis statement=\"…\" predictions='[\"…\"]'")
124
+ return 0
125
+
126
+
127
+ def cmd_add(args) -> int:
128
+ g = _graph(args)
129
+ if args.type not in TYPES or args.type in ("review", "investigation"):
130
+ raise GraphError(f"type: one of {', '.join(t for t in TYPES if t not in ('review', 'investigation'))} "
131
+ "(investigations: hypgraph init; reviews: hypgraph review)")
132
+ inv = _inv(g, args.inv)
133
+ n = g.create(author=_author(args), type=args.type, inv=inv, fields=_fields(args.fields),
134
+ links=_links(g, args.link, inv))
135
+ notes = "".join(f"\n note: {x}" for x in n.get("notes") or [])
136
+ _out(args, n, f"Added {n['code']} — {n['state']['sentence']}{notes}")
137
+ return 0
138
+
139
+
140
+ def cmd_link(args) -> int:
141
+ g = _graph(args)
142
+ inv = _inv(g, args.inv) if args.inv else ""
143
+ r = g.link(author=_author(args), src=g.resolve(args.src, inv), rel=args.rel, dst=g.resolve(args.dst, inv),
144
+ why=args.why or "", data=_fields(args.data))
145
+ _out(args, r, f"Linked {args.src} -{args.rel}-> {args.dst} (edge {r['edge']})"
146
+ + (" — turned round to the direction the relation allows" if r["flipped"] else ""))
147
+ return 0
148
+
149
+
150
+ def cmd_review(args) -> int:
151
+ g = _graph(args)
152
+ n = g.review(author=_author(args), target=g.resolve(args.node, args.inv or ""), verdict=args.verdict,
153
+ method=args.method, notes=args.notes)
154
+ _out(args, n, f"{n['code']} — {n['state']['sentence']}")
155
+ return 0
156
+
157
+
158
+ def cmd_revise(args) -> int:
159
+ g = _graph(args)
160
+ n = g.revise(author=_author(args), node=g.resolve(args.node, args.inv or ""), fields=_fields(args.fields),
161
+ reason=args.reason)
162
+ _out(args, n, f"{n['code']} v{n['v']} — {n['state']['sentence']}")
163
+ return 0
164
+
165
+
166
+ def cmd_withdraw(args) -> int:
167
+ g = _graph(args)
168
+ n = g.withdraw(author=_author(args), node=g.resolve(args.node, args.inv or ""), why=args.why)
169
+ _out(args, n, f"{n['code']} — {n['state']['sentence']}")
170
+ return 0
171
+
172
+
173
+ def cmd_ls(args) -> int:
174
+ g = _graph(args)
175
+ if args.type == "investigation" or (not args.inv and not args.type and not args.text and
176
+ len(g.investigations()) != 1):
177
+ rows = g.investigations()
178
+ _out(args, rows, "\n".join(f"{r['code']:<6} {r['state']['lifecycle']:<9} "
179
+ f"{sum(r['counts'].values()):>4} nodes {r['title']}" for r in rows)
180
+ or "No investigations yet — hypgraph init \"Title\" --objective \"…\" (or hypgraph demo)")
181
+ return 0
182
+ rows = g.search(text=args.text or "", type=args.type or "", inv=_inv(g, args.inv), limit=500)
183
+ _out(args, rows, "\n".join(f"{_line(r)}\n {r['state']['sentence']}" for r in rows) or "Nothing found.")
184
+ return 0
185
+
186
+
187
+ def cmd_show(args) -> int:
188
+ g = _graph(args)
189
+ nid = g.resolve(args.node, args.inv or "")
190
+ if args.why:
191
+ r = g.why(nid)
192
+ _out(args, r, f"WHY {args.node} — what it rests on:\n" + "\n".join(
193
+ f" {_line(n)}\n {n['state']['sentence']}" for n in r["nodes"] if n["id"] != nid))
194
+ elif args.impact:
195
+ r = g.impact(nid)
196
+ _out(args, r, f"IF {args.node} IS WRONG — {len(r['dependents'])} node(s) rest on it:\n" + "\n".join(
197
+ f" {' ' * (d['depth'] - 1)}{d['rel']} ← {_line(d['node'])}" for d in r["dependents"]))
198
+ else:
199
+ n = g.get(nid, history=args.history)
200
+ text = _show(n)
201
+ if args.history:
202
+ text += "\n versions:\n" + "\n".join(f" v{v['v']} {v['at'][:10]} {v['by']}: {v['reason'] or '(created)'}"
203
+ for v in n.get("versions", []))
204
+ _out(args, n, text)
205
+ return 0
206
+
207
+
208
+ def cmd_next(args) -> int:
209
+ g = _graph(args)
210
+ r = g.next_experiments(_inv(g, args.inv))
211
+ lines = [f"Next experiments ({r['rule']}):"]
212
+ lines += [f" {x['experiment']['code']:<5} score {x['score']:<6} separates {x['separates']}"
213
+ f"{', power check' if x['power_check'] else ''}{', DUPLICATE' if x['duplicate'] else ''} — "
214
+ f"{x['experiment']['title'][:80]}" for x in r["ranked"]] or [" (none planned)"]
215
+ if r["untested_hypotheses"]:
216
+ lines.append("Untested hypotheses: " + ", ".join(h["code"] for h in r["untested_hypotheses"]))
217
+ _out(args, r, "\n".join(lines))
218
+ return 0
219
+
220
+
221
+ def cmd_work(args) -> int:
222
+ g = _graph(args)
223
+ w = g.work_map(_inv(g, args.inv))
224
+ sec = lambda name, rows: f"{name} ({len(rows)}): " + (", ".join(x["code"] for x in rows) or "—") # noqa: E731
225
+ _out(args, w, "\n".join([
226
+ "Being worked: " + (", ".join(f"{L['agent']} → {L['node']['code']}" for L in w["leases"]) or "—"),
227
+ sec("Review queue", w["review_queue"]), sec("Needs revalidation", w["revalidation"]),
228
+ sec("Contested", w["contested"]), sec("Decisions to revisit", w["decisions_to_revisit"]),
229
+ f"Duplicate experiment groups: {len(w['duplicates'])}"]))
230
+ return 0
231
+
232
+
233
+ def cmd_report(args) -> int:
234
+ g = _graph(args)
235
+ r = g.report(_inv(g, args.inv))
236
+ if args.output:
237
+ Path(args.output).write_text(r["markdown"] + "\n\n## Interpretation\n\n", encoding="utf-8")
238
+ print(f"Wrote the facts to {args.output}; write your interpretation under them.")
239
+ return 0
240
+ _out(args, r, r["markdown"])
241
+ return 0
242
+
243
+
244
+ def cmd_verify(args) -> int:
245
+ target = args.target or args.db
246
+ if str(target).endswith(".json"):
247
+ res = verify_bundle(json.loads(Path(target).read_text(encoding="utf-8")))
248
+ else:
249
+ res = verify_file(target)
250
+ ok = res.get("ok")
251
+ text = (f"✓ {target}: {res.get('checked')} events verified — chain intact, every row matches the event that "
252
+ f"wrote it. Head {str(res.get('head', ''))[:16]}…" if ok else f"✗ {target}: {res.get('problem')}")
253
+ if res.get("warning"):
254
+ text += "\n note: " + res["warning"]
255
+ _out(args, res, text)
256
+ return 0 if ok else 1
257
+
258
+
259
+ def cmd_export(args) -> int:
260
+ g = _graph(args)
261
+ inv = g.resolve(args.inv) if args.inv else ""
262
+ b = g.export(inv)
263
+ data = json.dumps(b, ensure_ascii=False, separators=(",", ":") if args.compact else None,
264
+ indent=None if args.compact else 1)
265
+ if args.output:
266
+ Path(args.output).write_text(data, encoding="utf-8")
267
+ print(f"Wrote {args.output}: {len(b['events'])} events, {len(b['nodes'])} nodes"
268
+ + ("" if b["complete"] else f" ({b['redacted_events']} events outside the investigation redacted)"))
269
+ else:
270
+ print(data)
271
+ return 0
272
+
273
+
274
+ def cmd_import(args) -> int:
275
+ b = json.loads(Path(args.bundle).read_text(encoding="utf-8"))
276
+ if Path(args.db).exists():
277
+ raise GraphError(f"{args.db} exists — import into a new file (--db new.hypgraph)")
278
+ g = Graph.from_bundle(b, args.db)
279
+ print(f"Imported into {g.path}: {g.verify()['checked']} events, verified.")
280
+ return 0
281
+
282
+
283
+ def cmd_demo(args) -> int:
284
+ from .demos import broad_street
285
+ if Path(args.db).exists():
286
+ raise GraphError(f"{args.db} exists — choose another file: hypgraph demo --db broad-street.hypgraph")
287
+ g, inv, story = broad_street.build(path=args.db)
288
+ if args.json:
289
+ _out(args, {"db": g.path, "inv": inv, "story": story})
290
+ return 0
291
+ for s in story:
292
+ print(f"{s['at']} {s['actor']:<10} {s['text']}")
293
+ if "refused" in s:
294
+ print(" ✗ the record refused it:\n " +
295
+ s["refused"].replace("\n", "\n "))
296
+ print(f"\nBuilt {broad_street.TITLE!r} into {g.path}.\nExplore it: hypgraph --db {args.db} serve"
297
+ f" · hypgraph --db {args.db} report")
298
+ return 0
299
+
300
+
301
+ def cmd_schema(args) -> int:
302
+ d = describe()
303
+ if args.json:
304
+ _out(args, d)
305
+ return 0
306
+ lines = [f"{d['format']} — node types:"]
307
+ lines += [f" {t:<13} {v['prefix']:<4} {v['about']}" for t, v in d["types"].items()]
308
+ lines += ["", "relations:"] + [f" {r:<14} {v['about']}" for r, v in d["relations"].items()]
309
+ print("\n".join(lines))
310
+ return 0
311
+
312
+
313
+ def skill_text() -> str:
314
+ return (Path(__file__).parent / "skill" / "SKILL.md").read_text(encoding="utf-8")
315
+
316
+
317
+ def cmd_skill(args) -> int:
318
+ """Print the agent skill, or install it where Claude Code (and other Agent Skills hosts) look for skills."""
319
+ if not args.install:
320
+ print(skill_text())
321
+ return 0
322
+ base = Path.home() if args.user else Path(args.dir or ".")
323
+ dest = base / ".claude" / "skills" / "hypgraph" / "SKILL.md"
324
+ dest.parent.mkdir(parents=True, exist_ok=True)
325
+ dest.write_text(skill_text(), encoding="utf-8")
326
+ print(f"Installed the hypgraph skill at {dest}")
327
+ return 0
328
+
329
+
330
+ def cmd_serve(args) -> int:
331
+ from .server import serve
332
+ serve(args.db, host=args.host, port=args.port, writable=args.writable, open_browser=not args.no_browser)
333
+ return 0
334
+
335
+
336
+ def cmd_mcp(args) -> int:
337
+ from .mcp import run
338
+ run(args.db, author=_author(args) if args.author else None, transport=args.transport, port=args.port)
339
+ return 0
340
+
341
+
342
+ def build_parser() -> argparse.ArgumentParser:
343
+ p = argparse.ArgumentParser(prog="hypgraph", description="Don't believe the hype. Test the hyp.",
344
+ formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("\n\n")[1])
345
+ p.add_argument("--db", default=os.getenv("HYPGRAPH_DB", "research.hypgraph"), help="the graph file")
346
+ p.add_argument("--as", dest="author", default="", help="who you are writing as")
347
+ p.add_argument("--json", action="store_true", help="machine-readable output")
348
+ p.add_argument("--version", action="version", version=f"hypgraph {__version__}")
349
+ sub = p.add_subparsers(dest="cmd", required=True, metavar="command")
350
+
351
+ def cmd(name, fn, help, **kw):
352
+ s = sub.add_parser(name, help=help, **kw)
353
+ s.set_defaults(fn=fn)
354
+ s.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
355
+ return s
356
+
357
+ s = cmd("init", cmd_init, "start an investigation")
358
+ s.add_argument("title")
359
+ s.add_argument("--objective", required=True, help="what you want to find out, in a sentence")
360
+ s.add_argument("--reviewers", default="", help="comma-separated: whose verdicts count")
361
+ s.add_argument("fields", nargs="*", help="more fields as key=value (scope=…, success_criteria=…)")
362
+ s = cmd("add", cmd_add, "add a question, hypothesis, experiment, run, result, claim, source, decision …")
363
+ s.add_argument("type")
364
+ s.add_argument("fields", nargs="*", help="key=value; JSON values for lists and objects")
365
+ s.add_argument("--inv", default="")
366
+ s.add_argument("--link", action="append", help="rel:to:NODE[:why] or rel:from:NODE[:why]; repeatable")
367
+ s = cmd("link", cmd_link, "link two nodes")
368
+ s.add_argument("src"), s.add_argument("rel"), s.add_argument("dst")
369
+ s.add_argument("--why", default="")
370
+ s.add_argument("--inv", default="")
371
+ s.add_argument("data", nargs="*", help="key=value edge data (expected=…, differs=…, role=…)")
372
+ s = cmd("review", cmd_review, "review a node's current version")
373
+ s.add_argument("node"), s.add_argument("verdict")
374
+ s.add_argument("--method", required=True, help="how you checked")
375
+ s.add_argument("--notes", required=True, help="what you found")
376
+ s.add_argument("--inv", default="")
377
+ s = cmd("revise", cmd_revise, "add a new version of a node")
378
+ s.add_argument("node")
379
+ s.add_argument("fields", nargs="*")
380
+ s.add_argument("--reason", required=True)
381
+ s.add_argument("--inv", default="")
382
+ s = cmd("withdraw", cmd_withdraw, "withdraw a node (it stays on record)")
383
+ s.add_argument("node")
384
+ s.add_argument("--why", required=True)
385
+ s.add_argument("--inv", default="")
386
+ s = cmd("ls", cmd_ls, "list investigations, or the nodes of one")
387
+ s.add_argument("text", nargs="?", default="")
388
+ s.add_argument("--type", default="")
389
+ s.add_argument("--inv", default="")
390
+ s = cmd("show", cmd_show, "one node, its standing and links")
391
+ s.add_argument("node")
392
+ s.add_argument("--why", action="store_true", help="what it rests on")
393
+ s.add_argument("--impact", action="store_true", help="what rests on it")
394
+ s.add_argument("--history", action="store_true", help="its versions")
395
+ s.add_argument("--inv", default="")
396
+ for name, fn, h in (("next", cmd_next, "experiments ranked by how well they separate hypotheses"),
397
+ ("work", cmd_work, "review queue, revalidation, contested, decisions to revisit")):
398
+ cmd(name, fn, h).add_argument("--inv", default="")
399
+ s = cmd("report", cmd_report, "the facts of an investigation, generated from the record")
400
+ s.add_argument("--inv", default="")
401
+ s.add_argument("-o", "--output", default="")
402
+ s = cmd("verify", cmd_verify, "check a graph file or a bundle")
403
+ s.add_argument("target", nargs="?", default="")
404
+ s = cmd("export", cmd_export, "write a verifiable bundle")
405
+ s.add_argument("--inv", default="", help="one investigation (events outside it are redacted)")
406
+ s.add_argument("-o", "--output", default="")
407
+ s.add_argument("--compact", action="store_true")
408
+ s = cmd("import", cmd_import, "rebuild a graph file from a complete bundle")
409
+ s.add_argument("bundle")
410
+ cmd("demo", cmd_demo, "build the Broad Street (1854) teaching investigation")
411
+ cmd("schema", cmd_schema, "the node types and relations")
412
+ s = cmd("skill", cmd_skill, "print the agent skill (SKILL.md), or --install it for Claude Code")
413
+ s.add_argument("--install", action="store_true", help="write .claude/skills/hypgraph/SKILL.md")
414
+ s.add_argument("--user", action="store_true", help="install under your home directory instead of this project")
415
+ s.add_argument("--dir", default="", help="project directory to install into (default: here)")
416
+ s = cmd("serve", cmd_serve, "the Atlas explorer and JSON API on localhost")
417
+ s.add_argument("--host", default="127.0.0.1")
418
+ s.add_argument("--port", type=int, default=8765)
419
+ s.add_argument("--writable", action="store_true", help="also accept writes (POST /api/graph/write)")
420
+ s.add_argument("--no-browser", action="store_true")
421
+ s = cmd("mcp", cmd_mcp, "an MCP server for agents (needs hypgraph[mcp])")
422
+ s.add_argument("--transport", default="stdio", choices=["stdio", "http"])
423
+ s.add_argument("--port", type=int, default=8766)
424
+ return p
425
+
426
+
427
+ def main(argv: list[str] | None = None) -> int:
428
+ for stream in (sys.stdout, sys.stderr):
429
+ try:
430
+ stream.reconfigure(encoding="utf-8", errors="replace") # Windows consoles default to cp1252
431
+ except (AttributeError, ValueError):
432
+ pass
433
+ args = build_parser().parse_args(argv)
434
+ try:
435
+ return args.fn(args)
436
+ except GraphError as e:
437
+ print(f"refused: {e}", file=sys.stderr)
438
+ return 2
439
+ except KeyError as e:
440
+ print(f"not found: {e.args[0] if e.args else e}", file=sys.stderr)
441
+ return 3
442
+
443
+
444
+ if __name__ == "__main__":
445
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ """Teaching investigations that ship with HypGraph. ``hypgraph demo`` builds one into a file you can explore."""
2
+
3
+ DEMOS = {"broad-street": "hypgraph.demos.broad_street"}