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/server.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``hypgraph serve`` — the JSON API and the Atlas explorer, stdlib only.
|
|
3
|
+
|
|
4
|
+
GET / the Atlas
|
|
5
|
+
GET /api/graph/investigations {investigations, stats}
|
|
6
|
+
GET /api/graph/subgraph?inv=&types= nodes (with standing) and live edges
|
|
7
|
+
GET /api/graph/node/{id}?history=1 one node, its links, its versions
|
|
8
|
+
GET /api/graph/why/{id} what it rests on /impact/{id} what rests on it
|
|
9
|
+
GET /api/graph/neighborhood/{id}?depth=
|
|
10
|
+
GET /api/graph/search?text=&type=&inv=&limit=
|
|
11
|
+
GET /api/graph/events?after=&inv=&limit=
|
|
12
|
+
GET /api/graph/next?inv= /work?inv= /cost?inv=&node= /report?inv= /verify /schema
|
|
13
|
+
GET /api/graph/export?inv= a verifiable bundle
|
|
14
|
+
POST /api/graph/write only with --writable: {op, author, ...} (create | revise | link | retract |
|
|
15
|
+
withdraw | review | lease); the policy's reserved authors are refused
|
|
16
|
+
|
|
17
|
+
It binds to 127.0.0.1 by default and refuses requests whose Host isn't the address it serves (DNS rebinding) and
|
|
18
|
+
cross-origin writes. It authenticates nobody: put it behind your own auth before exposing it beyond your machine.
|
|
19
|
+
``dispatch()`` is the routing on its own, for hosts that bring their own web framework.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import mimetypes
|
|
26
|
+
import threading
|
|
27
|
+
import webbrowser
|
|
28
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
31
|
+
|
|
32
|
+
from .graph import Graph, GraphError
|
|
33
|
+
from .schema import describe
|
|
34
|
+
from .ui import static_dir
|
|
35
|
+
|
|
36
|
+
WRITES = {
|
|
37
|
+
"create": ("create", ("type", "fields", "inv", "links", "reason")),
|
|
38
|
+
"revise": ("revise", ("node", "fields", "reason")),
|
|
39
|
+
"link": ("link", ("src", "rel", "dst", "why", "data")),
|
|
40
|
+
"retract": ("retract", ("edge", "why")),
|
|
41
|
+
"withdraw": ("withdraw", ("node", "why")),
|
|
42
|
+
"review": ("review", ("target", "verdict", "method", "notes")),
|
|
43
|
+
"lease": ("lease", ("node", "task", "minutes", "release", "note")),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _int(q: dict, k: str, default: int, lo: int, hi: int) -> int:
|
|
48
|
+
try:
|
|
49
|
+
return max(lo, min(int(q.get(k, default)), hi))
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def dispatch(g: Graph, method: str, path: str, query: dict | None = None, body: dict | None = None,
|
|
55
|
+
writable: bool = False) -> tuple[int, object]:
|
|
56
|
+
"""Route one API call. ``path`` is what follows /api/graph (e.g. "/node/n1a2b"). Returns (status, JSON value)."""
|
|
57
|
+
q = {k: (v[0] if isinstance(v, list) else v) for k, v in (query or {}).items()}
|
|
58
|
+
parts = [unquote(p) for p in path.strip("/").split("/") if p]
|
|
59
|
+
try:
|
|
60
|
+
if method == "POST":
|
|
61
|
+
if parts != ["write"]:
|
|
62
|
+
return 404, {"detail": "no such route"}
|
|
63
|
+
if not writable:
|
|
64
|
+
return 403, {"detail": "this server is read-only (start it with --writable to accept writes)"}
|
|
65
|
+
b = body or {}
|
|
66
|
+
author = str(b.get("author") or "").strip()
|
|
67
|
+
if not author or author in g.policy.reserved:
|
|
68
|
+
return 403, {"detail": "author missing, or reserved (" + ", ".join(sorted(g.policy.reserved)) + ")"}
|
|
69
|
+
spec = WRITES.get(str(b.get("op") or ""))
|
|
70
|
+
if not spec:
|
|
71
|
+
return 400, {"detail": f"op must be one of {', '.join(WRITES)}"}
|
|
72
|
+
fn, keys = spec
|
|
73
|
+
return 200, getattr(g, fn)(author=author, **{k: b[k] for k in keys if k in b})
|
|
74
|
+
if method != "GET" or not parts:
|
|
75
|
+
return 404, {"detail": "no such route"}
|
|
76
|
+
head, arg = parts[0], (parts[1] if len(parts) > 1 else "")
|
|
77
|
+
inv = q.get("inv", "")
|
|
78
|
+
if head == "investigations":
|
|
79
|
+
return 200, {"investigations": g.investigations(), "stats": g.stats()}
|
|
80
|
+
if head == "subgraph":
|
|
81
|
+
types = [t for t in str(q.get("types", "")).split(",") if t] or None
|
|
82
|
+
return 200, g.subgraph(inv, types=types)
|
|
83
|
+
if head == "node" and arg:
|
|
84
|
+
return 200, g.get(arg, history=q.get("history") in ("1", "true"))
|
|
85
|
+
if head == "why" and arg:
|
|
86
|
+
return 200, g.why(arg)
|
|
87
|
+
if head == "impact" and arg:
|
|
88
|
+
return 200, g.impact(arg)
|
|
89
|
+
if head == "neighborhood" and arg:
|
|
90
|
+
return 200, g.neighborhood(arg, depth=_int(q, "depth", 1, 1, 4))
|
|
91
|
+
if head == "search":
|
|
92
|
+
return 200, {"results": g.search(text=q.get("text", ""), type=q.get("type", ""), inv=inv,
|
|
93
|
+
limit=_int(q, "limit", 50, 1, 500))}
|
|
94
|
+
if head == "events":
|
|
95
|
+
return 200, g.events(after=_int(q, "after", 0, 0, 10 ** 12), inv=inv, limit=_int(q, "limit", 500, 1, 5000))
|
|
96
|
+
if head == "next":
|
|
97
|
+
return 200, g.next_experiments(inv, limit=_int(q, "limit", 20, 1, 100))
|
|
98
|
+
if head == "work":
|
|
99
|
+
return 200, g.work_map(inv)
|
|
100
|
+
if head == "cost":
|
|
101
|
+
return 200, g.cost(inv, q.get("node", ""))
|
|
102
|
+
if head == "report":
|
|
103
|
+
return 200, g.report(inv)
|
|
104
|
+
if head == "verify":
|
|
105
|
+
return 200, g.verify_chain()
|
|
106
|
+
if head == "export":
|
|
107
|
+
return 200, g.export(inv)
|
|
108
|
+
if head == "schema":
|
|
109
|
+
return 200, describe()
|
|
110
|
+
if head == "stats":
|
|
111
|
+
return 200, g.stats()
|
|
112
|
+
return 404, {"detail": "no such route"}
|
|
113
|
+
except KeyError as e:
|
|
114
|
+
return 404, {"detail": f"not found: {e.args[0] if e.args else e}"}
|
|
115
|
+
except (GraphError, ValueError, TypeError) as e:
|
|
116
|
+
return 400, {"detail": str(e)}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def make_handler(g: Graph, *, writable: bool, hosts: set[str], root: Path):
|
|
120
|
+
class Handler(BaseHTTPRequestHandler):
|
|
121
|
+
server_version = "hypgraph"
|
|
122
|
+
|
|
123
|
+
def log_message(self, fmt, *args): # quiet by default
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def _send(self, status: int, data: bytes, ctype: str) -> None:
|
|
127
|
+
self.send_response(status)
|
|
128
|
+
self.send_header("Content-Type", ctype)
|
|
129
|
+
self.send_header("Content-Length", str(len(data)))
|
|
130
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
131
|
+
self.send_header("Cache-Control", "no-store")
|
|
132
|
+
self.end_headers()
|
|
133
|
+
self.wfile.write(data)
|
|
134
|
+
|
|
135
|
+
def _json(self, status: int, obj) -> None:
|
|
136
|
+
self._send(status, json.dumps(obj, ensure_ascii=False, default=str).encode(), "application/json")
|
|
137
|
+
|
|
138
|
+
def _host_ok(self) -> bool:
|
|
139
|
+
host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]").lower()
|
|
140
|
+
return host in hosts
|
|
141
|
+
|
|
142
|
+
def do_GET(self): # noqa: N802
|
|
143
|
+
if not self._host_ok():
|
|
144
|
+
return self._json(421, {"detail": "unexpected Host header"})
|
|
145
|
+
u = urlparse(self.path)
|
|
146
|
+
if u.path.startswith("/api/graph"):
|
|
147
|
+
status, obj = dispatch(g, "GET", u.path[len("/api/graph"):], parse_qs(u.query), writable=writable)
|
|
148
|
+
return self._json(status, obj)
|
|
149
|
+
rel = "index.html" if u.path in ("", "/") else unquote(u.path).lstrip("/")
|
|
150
|
+
f = (root / rel).resolve()
|
|
151
|
+
if not f.is_relative_to(root) or not f.is_file():
|
|
152
|
+
return self._json(404, {"detail": "not found"})
|
|
153
|
+
ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
|
154
|
+
if ctype.startswith("text/") or ctype in ("application/javascript", "image/svg+xml"):
|
|
155
|
+
ctype += "; charset=utf-8"
|
|
156
|
+
self._send(200, f.read_bytes(), ctype)
|
|
157
|
+
|
|
158
|
+
def do_POST(self): # noqa: N802
|
|
159
|
+
if not self._host_ok():
|
|
160
|
+
return self._json(421, {"detail": "unexpected Host header"})
|
|
161
|
+
origin = self.headers.get("Origin")
|
|
162
|
+
if origin and urlparse(origin).hostname not in hosts:
|
|
163
|
+
return self._json(403, {"detail": "cross-origin write refused"})
|
|
164
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
165
|
+
if n > 2_000_000:
|
|
166
|
+
return self._json(413, {"detail": "request too large"})
|
|
167
|
+
try:
|
|
168
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
return self._json(400, {"detail": "body must be JSON"})
|
|
171
|
+
u = urlparse(self.path)
|
|
172
|
+
if not u.path.startswith("/api/graph"):
|
|
173
|
+
return self._json(404, {"detail": "no such route"})
|
|
174
|
+
status, obj = dispatch(g, "POST", u.path[len("/api/graph"):], body=body, writable=writable)
|
|
175
|
+
self._json(status, obj)
|
|
176
|
+
return Handler
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def make_server(graph: Graph | str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False):
|
|
180
|
+
g = graph if isinstance(graph, Graph) else Graph(graph)
|
|
181
|
+
hosts = {host.lower(), "localhost", "127.0.0.1", "::1"} if host in ("127.0.0.1", "localhost", "::1") else \
|
|
182
|
+
{host.lower()}
|
|
183
|
+
root = static_dir().resolve()
|
|
184
|
+
return ThreadingHTTPServer((host, port), make_handler(g, writable=writable, hosts=hosts, root=root))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def serve(db: str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False,
|
|
188
|
+
open_browser: bool = True) -> None:
|
|
189
|
+
if not Path(db).exists():
|
|
190
|
+
print(f"{db} doesn't exist yet — `hypgraph demo` builds one to explore, `hypgraph init` starts your own.")
|
|
191
|
+
return
|
|
192
|
+
srv = make_server(db, host, port, writable=writable)
|
|
193
|
+
url = f"http://{host}:{srv.server_address[1]}/"
|
|
194
|
+
print(f"HypGraph Atlas on {url} ({db}{', writable' if writable else ', read-only'}) — Ctrl+C to stop")
|
|
195
|
+
if open_browser:
|
|
196
|
+
threading.Timer(0.6, lambda: webbrowser.open(url)).start()
|
|
197
|
+
try:
|
|
198
|
+
srv.serve_forever()
|
|
199
|
+
except KeyboardInterrupt:
|
|
200
|
+
pass
|
|
201
|
+
finally:
|
|
202
|
+
srv.server_close()
|
hypgraph/skill/SKILL.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hypgraph
|
|
3
|
+
description: Keep a research record that can't flatter itself — hypotheses with predictions, experiments with expected outcomes written before running, hashed runs, results, and claims whose every number is bound to a result. Use when investigating a question with evidence (science, data analysis, debugging a hypothesis, literature claims, ML evaluation), when asked to record findings, cite what is established, plan the next experiment, or write a summary that must not overclaim. Works through the `hypgraph` CLI or its MCP server.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# HypGraph — test the hyp
|
|
7
|
+
|
|
8
|
+
HypGraph is an append-only, hash-chained research record. You write nodes (question, hypothesis, experiment, run,
|
|
9
|
+
result, claim, source, decision) and typed links. **You cannot set how well anything is known.** Review state, risk,
|
|
10
|
+
independent support and evidence strength are derived on every read. When the record refuses a write, the message
|
|
11
|
+
says exactly what to fix: fix that, don't route around it.
|
|
12
|
+
|
|
13
|
+
Prefer the MCP tools if a `hypgraph` server is connected (`overview`, `find`, `show`, `add`, `link`, `review`,
|
|
14
|
+
`register_run`, `next_experiments`, `work_queue`, `report`, `verify`). Otherwise use the CLI below, adding `--json`
|
|
15
|
+
when you need to parse the output. Install it with `pip install hypgraph`.
|
|
16
|
+
|
|
17
|
+
## Setup (CLI)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export HYPGRAPH_DB=study.hypgraph HYPGRAPH_AUTHOR=<your-name> # or --db / --as on every command
|
|
21
|
+
hypgraph ls # what investigations exist
|
|
22
|
+
hypgraph init "Title" --objective "What you want to find out, in a sentence." --reviewers <reviewer-names>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The names `lab`, `operator`, `importer` and `system` are reserved. Never write as someone else.
|
|
26
|
+
|
|
27
|
+
## The method
|
|
28
|
+
|
|
29
|
+
1. **Read first.** Run `hypgraph ls`, `hypgraph ls --type claim`, `hypgraph show CL3 --why` and `hypgraph work`
|
|
30
|
+
before re-deriving anything. Don't cite a node whose sentence says CONTESTED, AT RISK or UNSUPPORTED without
|
|
31
|
+
checking it.
|
|
32
|
+
2. **Hypotheses with predictions.** Name rival hypotheses too.
|
|
33
|
+
```bash
|
|
34
|
+
hypgraph add hypothesis statement="Caching causes the latency spike." predictions='["Spike disappears with cache off."]'
|
|
35
|
+
hypgraph link H2 alternative_to H1
|
|
36
|
+
```
|
|
37
|
+
3. **Experiments that can fail.** Write the expected outcome per hypothesis **before** running:
|
|
38
|
+
```bash
|
|
39
|
+
hypgraph add experiment aim="Toggle the cache under fixed load." protocol="10 runs each, cache on/off, same load script." \
|
|
40
|
+
success_criteria="Median p99 differs by more than run-to-run spread." power_check="Inject a known 50 ms delay; confirm it is detected."
|
|
41
|
+
hypgraph link X1 tests H1 expected="p99 drops with cache off"
|
|
42
|
+
hypgraph link X1 tests H2 expected="no change with cache off"
|
|
43
|
+
```
|
|
44
|
+
`hypgraph next` ranks experiments by how many hypotheses they separate, whether they can show their own power,
|
|
45
|
+
and their cost.
|
|
46
|
+
4. **Runs and results.** Hash what you ran: `hypgraph.file_sha256(path)` or MCP `register_run`. Results carry values
|
|
47
|
+
as `{name: {value}}` or `{name: {k, n}}`:
|
|
48
|
+
```bash
|
|
49
|
+
hypgraph add result summary="p99 fell from 840 ms to 210 ms with the cache off." \
|
|
50
|
+
values='{"p99_on_ms": {"value": 840}, "p99_off_ms": {"value": 210}, "runs": {"value": 10}}' outcome=positive
|
|
51
|
+
```
|
|
52
|
+
5. **Claims carry their numbers.**
|
|
53
|
+
```bash
|
|
54
|
+
hypgraph add claim statement="With the cache off, p99 fell from 840 ms to 210 ms." \
|
|
55
|
+
scope="Staging, fixed load script, 10 runs per condition, build 4f2a." \
|
|
56
|
+
measurements='[{"name":"on","result":"RS1","key":"p99_on_ms","value":840},{"name":"off","result":"RS1","key":"p99_off_ms","value":210}]' \
|
|
57
|
+
--link "supports:from:RS1:RS1 is the measurement this claim states"
|
|
58
|
+
```
|
|
59
|
+
6. **Ask for review.** Only the investigation's reviewers set standing, never on their own work. Revising a claim
|
|
60
|
+
makes earlier reviews stale and flags decisions based on it.
|
|
61
|
+
7. **Summaries start from the record.** `hypgraph report -o facts.md` generates the facts, which can't restate a
|
|
62
|
+
withdrawn claim or override a verdict. Write your interpretation underneath.
|
|
63
|
+
|
|
64
|
+
## Refusals and their fixes
|
|
65
|
+
|
|
66
|
+
| The record says | Do this |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `numbers in the statement that no bound measurement … account for: 30` | Add a measurement binding that number to a result field, or put design constants in the scope. Don't delete the number to sneak past. |
|
|
69
|
+
| `'always' is absolute` | Back it with k of n where k = 0 or n, or say "in k of n" / "rarely". |
|
|
70
|
+
| `an exclusion needs a search that recovered a planted answer` | Run a power check (plant a known answer and recover it) and set `power=demonstrated` on the result, or file the claim as `kind=inconclusive`. |
|
|
71
|
+
| `scope: say under which conditions this holds` | Name the data, method and sample. |
|
|
72
|
+
| `link the evidence` | Add a `supports` link from the result, with a reason. |
|
|
73
|
+
| `tests …: give data.expected` | Say what the experiment should show if that hypothesis is true. |
|
|
74
|
+
| `you can't review your own work` | Ask a named reviewer. |
|
|
75
|
+
| `… is published — its record is fixed` | Open a follow-up investigation and link to what you build on. |
|
|
76
|
+
|
|
77
|
+
## Reading standing
|
|
78
|
+
|
|
79
|
+
`active claim · checked (fisher:pass, v2) · 2 independent lines of support · replicated on new data · no external replication`
|
|
80
|
+
|
|
81
|
+
- **review** — `unchecked`, `checked`, `contested`, or `review stale` (vN not yet re-checked).
|
|
82
|
+
- **independent lines** — results sharing a run, data file or source count once.
|
|
83
|
+
- **evidence** — the strongest line: unverified report < agent-reported run < lab-recorded run < reproduced in
|
|
84
|
+
project < separate implementation < replicated on new data < external source.
|
|
85
|
+
- **AT RISK** — a premise fell. **REVISIT** — a decision's basis changed. **UNSUPPORTED** — every support fell.
|
|
86
|
+
|
|
87
|
+
Report standing as the record states it. Don't upgrade "unchecked" to "shown", or "1 line, agent-reported" to
|
|
88
|
+
"established".
|
|
89
|
+
|
|
90
|
+
## Useful commands
|
|
91
|
+
|
|
92
|
+
`hypgraph show NODE [--why|--impact|--history]` · `hypgraph work` · `hypgraph next` · `hypgraph revise NODE k=v --reason "…"`
|
|
93
|
+
· `hypgraph withdraw NODE --why "…"` · `hypgraph export -o bundle.json` · `hypgraph verify [bundle.json]` ·
|
|
94
|
+
`hypgraph serve` (the Atlas explorer) · `hypgraph schema` (every type and relation).
|
hypgraph/store.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage: one SQLite file, every table append-only (triggers refuse UPDATE and DELETE), every write an event in a
|
|
3
|
+
SHA-256 hash chain. ``verify()`` recomputes the chain and checks each stored version body and edge against the event
|
|
4
|
+
that wrote it, so an edit made to the file outside the library — even one that drops the triggers first — shows up.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
SCHEMA = [
|
|
16
|
+
"""CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, type TEXT NOT NULL, code TEXT NOT NULL DEFAULT '',
|
|
17
|
+
inv TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, created_by TEXT NOT NULL)""",
|
|
18
|
+
"""CREATE TABLE IF NOT EXISTS versions (node TEXT NOT NULL, v INTEGER NOT NULL, at TEXT NOT NULL,
|
|
19
|
+
by TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', body TEXT NOT NULL, PRIMARY KEY (node, v))""",
|
|
20
|
+
"""CREATE TABLE IF NOT EXISTS edges (id INTEGER PRIMARY KEY AUTOINCREMENT, src TEXT NOT NULL, src_v INTEGER,
|
|
21
|
+
rel TEXT NOT NULL, dst TEXT NOT NULL, dst_v INTEGER, data TEXT NOT NULL DEFAULT '{}', at TEXT NOT NULL,
|
|
22
|
+
by TEXT NOT NULL, why TEXT NOT NULL DEFAULT '')""",
|
|
23
|
+
"""CREATE TABLE IF NOT EXISTS retractions (edge INTEGER PRIMARY KEY, at TEXT NOT NULL, by TEXT NOT NULL,
|
|
24
|
+
why TEXT NOT NULL)""",
|
|
25
|
+
"""CREATE TABLE IF NOT EXISTS withdrawals (node TEXT PRIMARY KEY, at TEXT NOT NULL, by TEXT NOT NULL,
|
|
26
|
+
why TEXT NOT NULL)""",
|
|
27
|
+
"""CREATE TABLE IF NOT EXISTS leases (id INTEGER PRIMARY KEY AUTOINCREMENT, node TEXT NOT NULL, agent TEXT NOT NULL,
|
|
28
|
+
task TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL, at TEXT NOT NULL, expires TEXT NOT NULL DEFAULT '',
|
|
29
|
+
note TEXT NOT NULL DEFAULT '')""",
|
|
30
|
+
"""CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, actor TEXT NOT NULL,
|
|
31
|
+
kind TEXT NOT NULL, ref TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '{}', prev TEXT NOT NULL,
|
|
32
|
+
hash TEXT NOT NULL)""",
|
|
33
|
+
"CREATE INDEX IF NOT EXISTS idx_nodes_inv ON nodes(inv)",
|
|
34
|
+
"CREATE INDEX IF NOT EXISTS idx_nodes_inv_type ON nodes(inv, type)",
|
|
35
|
+
"CREATE INDEX IF NOT EXISTS idx_nodes_inv_code ON nodes(inv, code)",
|
|
36
|
+
"CREATE INDEX IF NOT EXISTS idx_events_actor ON events(actor, kind, at)",
|
|
37
|
+
"CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src)",
|
|
38
|
+
"CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst)",
|
|
39
|
+
"CREATE INDEX IF NOT EXISTS idx_leases_node ON leases(node, id)",
|
|
40
|
+
]
|
|
41
|
+
APPEND_ONLY = ("nodes", "versions", "edges", "retractions", "withdrawals", "leases", "events")
|
|
42
|
+
GENESIS = "0" * 64
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def now() -> str:
|
|
46
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def canon(obj: Any) -> str:
|
|
50
|
+
"""The canonical JSON every hash is taken over: sorted keys, no spaces, UTF-8 kept as is."""
|
|
51
|
+
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sha(text: str) -> str:
|
|
55
|
+
return hashlib.sha256(text.encode()).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def event_body(at: str, actor: str, kind: str, ref: str, payload: dict, prev: str) -> str:
|
|
59
|
+
"""The exact string an event's hash is over."""
|
|
60
|
+
return canon({"at": at, "actor": actor, "kind": kind, "ref": ref, "payload": payload, "prev": prev})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Conn(sqlite3.Connection):
|
|
64
|
+
"""A connection kept open per thread (opening a file on a slow host mount costs ~0.2 s). close() is a no-op;
|
|
65
|
+
really_close() closes it."""
|
|
66
|
+
def close(self) -> None: # noqa: D401
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def really_close(self) -> None:
|
|
70
|
+
super().close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def install(con: sqlite3.Connection, *, journal: str = "DELETE") -> None:
|
|
74
|
+
"""Create the tables and the append-only triggers. A rollback journal by default: WAL's shared-memory file isn't
|
|
75
|
+
reliable on network and VM host mounts, where research data often lives."""
|
|
76
|
+
try:
|
|
77
|
+
con.execute(f"PRAGMA journal_mode={journal}")
|
|
78
|
+
except sqlite3.OperationalError:
|
|
79
|
+
pass
|
|
80
|
+
for stmt in SCHEMA:
|
|
81
|
+
con.execute(stmt)
|
|
82
|
+
for t in APPEND_ONLY:
|
|
83
|
+
for op in ("UPDATE", "DELETE"):
|
|
84
|
+
con.execute(f"CREATE TRIGGER IF NOT EXISTS {t}_no_{op.lower()} BEFORE {op} ON {t} "
|
|
85
|
+
f"BEGIN SELECT RAISE(ABORT, '{t} is append-only'); END")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def append_event(con: sqlite3.Connection, actor: str, kind: str, ref: str, payload: dict, at: str) -> int:
|
|
89
|
+
row = con.execute("SELECT hash FROM events ORDER BY seq DESC LIMIT 1").fetchone()
|
|
90
|
+
prev = row[0] if row else GENESIS
|
|
91
|
+
h = sha(event_body(at, actor, kind, ref, payload, prev))
|
|
92
|
+
cur = con.execute("INSERT INTO events (at, actor, kind, ref, payload, prev, hash) VALUES (?,?,?,?,?,?,?)",
|
|
93
|
+
(at, actor, kind, ref, canon(payload), prev, h))
|
|
94
|
+
return cur.lastrowid
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def verify_connection(con: sqlite3.Connection) -> dict:
|
|
98
|
+
"""Recompute every event hash, then check stored content against what the events recorded: each version body
|
|
99
|
+
against its create / revise / review event's body_sha, each edge against its link event, and that no row exists
|
|
100
|
+
without its event."""
|
|
101
|
+
prev, n = GENESIS, 0
|
|
102
|
+
bodies: dict[tuple, str] = {}
|
|
103
|
+
links: dict[int, dict] = {}
|
|
104
|
+
for r in con.execute("SELECT seq, at, actor, kind, ref, payload, prev, hash FROM events ORDER BY seq"):
|
|
105
|
+
seq, at, actor, kind, ref, payload_s, prev_s, h = r
|
|
106
|
+
payload = json.loads(payload_s)
|
|
107
|
+
if prev_s != prev or sha(event_body(at, actor, kind, ref, payload, prev)) != h:
|
|
108
|
+
return {"ok": False, "broken_at": seq, "checked": n, "problem": "event hash chain broken"}
|
|
109
|
+
prev, n = h, n + 1
|
|
110
|
+
if kind in ("create", "revise"):
|
|
111
|
+
bodies[(ref, int(payload.get("v", 1)))] = payload.get("body_sha", "")
|
|
112
|
+
elif kind == "review":
|
|
113
|
+
bodies[(payload["review"], 1)] = payload.get("body_sha", "")
|
|
114
|
+
elif kind == "link":
|
|
115
|
+
links[int(payload["edge"])] = payload
|
|
116
|
+
for node, v, body in con.execute("SELECT node, v, body FROM versions"):
|
|
117
|
+
want = bodies.get((node, v))
|
|
118
|
+
if want is None:
|
|
119
|
+
return {"ok": False, "checked": n, "problem": f"version {node} v{v} has no event"}
|
|
120
|
+
if sha(body) != want:
|
|
121
|
+
return {"ok": False, "checked": n, "problem": f"version {node} v{v} differs from its event"}
|
|
122
|
+
for eid, rel, dst, dst_v, data in con.execute("SELECT id, rel, dst, dst_v, data FROM edges"):
|
|
123
|
+
want = links.get(eid)
|
|
124
|
+
if want is None:
|
|
125
|
+
return {"ok": False, "checked": n, "problem": f"edge {eid} has no event"}
|
|
126
|
+
if (want.get("rel"), want.get("dst"), want.get("dst_v")) != (rel, dst, dst_v) or \
|
|
127
|
+
sha(data) != want.get("data_sha"):
|
|
128
|
+
return {"ok": False, "checked": n, "problem": f"edge {eid} differs from its event"}
|
|
129
|
+
return {"ok": True, "checked": n, "head": prev}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify_file(path: str) -> dict:
|
|
133
|
+
"""Verify a graph file read-only (a backup, a copy someone sent you) without opening it for writing."""
|
|
134
|
+
from pathlib import Path
|
|
135
|
+
con = sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True)
|
|
136
|
+
try:
|
|
137
|
+
return verify_connection(con)
|
|
138
|
+
finally:
|
|
139
|
+
con.close()
|