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/graph.py
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Graph: one research record in one SQLite file (or in memory).
|
|
3
|
+
|
|
4
|
+
from hypgraph import Graph, Policy
|
|
5
|
+
g = Graph("study.hypgraph", policy=Policy(reviewers={"ada"}))
|
|
6
|
+
inv = g.create(author="me", type="investigation", fields={"title": "…", "objective": "…"})["id"]
|
|
7
|
+
|
|
8
|
+
Writes add rows and events and never change one; reads come from an in-memory snapshot that loads only rows newer
|
|
9
|
+
than the last read (every table is append-only), so a read after a write costs milliseconds. Status is derived on
|
|
10
|
+
every read (derive.py) and claims are checked on every write (checks.py).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import secrets
|
|
21
|
+
import sqlite3
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from datetime import datetime, timedelta, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Optional
|
|
28
|
+
|
|
29
|
+
from . import derive as D
|
|
30
|
+
from . import store
|
|
31
|
+
from .checks import claim_problems
|
|
32
|
+
from .policy import Policy
|
|
33
|
+
from .schema import REPLICATION, RELS, SAME_TYPE, TYPES, VERDICTS
|
|
34
|
+
from .store import canon, now
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GraphError(ValueError):
|
|
38
|
+
"""A write the graph refuses — the message says what to fix."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
SpendFn = Callable[..., dict]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clean_body(ntype: str, body: dict, *, partial_ok: bool = False) -> dict:
|
|
45
|
+
"""Validate and normalise a node's fields against its type (schema.TYPES)."""
|
|
46
|
+
spec = TYPES[ntype]["fields"]
|
|
47
|
+
if not isinstance(body, dict):
|
|
48
|
+
raise GraphError("fields must be an object")
|
|
49
|
+
unknown = sorted(set(body) - set(spec))
|
|
50
|
+
if unknown:
|
|
51
|
+
raise GraphError(f"{ntype} has no field(s) {unknown}; fields: {', '.join(spec)}")
|
|
52
|
+
out = {}
|
|
53
|
+
for k, (kind, n, req) in spec.items():
|
|
54
|
+
v = body.get(k)
|
|
55
|
+
if v in (None, "", [], {}):
|
|
56
|
+
if req and not partial_ok:
|
|
57
|
+
raise GraphError(f"{ntype} needs {k}")
|
|
58
|
+
continue
|
|
59
|
+
if kind in ("str", "text"):
|
|
60
|
+
v = str(v).strip()
|
|
61
|
+
if len(v) > n:
|
|
62
|
+
raise GraphError(f"{k} is longer than {n} characters")
|
|
63
|
+
if req and len(v) < (3 if kind == "str" else 10):
|
|
64
|
+
raise GraphError(f"{k}: say it in a full sentence")
|
|
65
|
+
elif kind.startswith("enum:"):
|
|
66
|
+
if str(v) not in kind[5:].split("|"):
|
|
67
|
+
raise GraphError(f"{k} must be one of {kind[5:].replace('|', ', ')}")
|
|
68
|
+
v = str(v)
|
|
69
|
+
elif kind == "list":
|
|
70
|
+
if isinstance(v, str):
|
|
71
|
+
v = [v]
|
|
72
|
+
if not isinstance(v, list) or len(v) > n:
|
|
73
|
+
raise GraphError(f"{k} must be a list of at most {n} items")
|
|
74
|
+
elif kind == "dict":
|
|
75
|
+
if not isinstance(v, dict) or len(canon(v)) > n:
|
|
76
|
+
raise GraphError(f"{k} must be an object (at most {n} characters)")
|
|
77
|
+
elif kind == "bool":
|
|
78
|
+
v = bool(v)
|
|
79
|
+
elif kind == "num":
|
|
80
|
+
v = float(v)
|
|
81
|
+
if not math.isfinite(v):
|
|
82
|
+
raise GraphError(f"{k} must be a finite number")
|
|
83
|
+
elif kind == "any":
|
|
84
|
+
if len(canon(v)) > n:
|
|
85
|
+
raise GraphError(f"{k} is too long")
|
|
86
|
+
out[k] = v
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _Writer(threading.local):
|
|
91
|
+
task = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class writing_for:
|
|
95
|
+
"""``with g.writing_for(task_id):`` — every event written inside carries ``task`` in its payload, so the work
|
|
96
|
+
behind a node can be traced to the task (and its cost) that made it."""
|
|
97
|
+
def __init__(self, graph: "Graph", task: str | None):
|
|
98
|
+
self.graph = graph
|
|
99
|
+
self.task = re.sub(r"[^A-Za-z0-9:_.\-]", "", str(task or ""))[:80]
|
|
100
|
+
|
|
101
|
+
def __enter__(self):
|
|
102
|
+
self.prev, self.graph._writer.task = self.graph._writer.task, self.task
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __exit__(self, *exc):
|
|
106
|
+
self.graph._writer.task = self.prev
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Graph:
|
|
110
|
+
"""A research record. ``path`` is a SQLite file (created if missing); None or ":memory:" keeps it in memory.
|
|
111
|
+
|
|
112
|
+
``policy`` says who reviews and who the trusted authors are (policy.Policy). ``spend`` is an optional hook
|
|
113
|
+
``spend(inv, tasks, since=None) -> {usd, calls, input_tokens, cached_tokens, output_tokens, by_task: [...],
|
|
114
|
+
...}`` that cost() uses to price the work behind an investigation; without it, cost() reports no money.
|
|
115
|
+
``clock`` returns the ISO time stamped on each write (default: now, UTC) — a host may inject its own; callers
|
|
116
|
+
can't pass a time, except the importer bringing in a legacy record with its original dates."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, path: str | os.PathLike | None = None, *, policy: Policy | None = None,
|
|
119
|
+
spend: SpendFn | None = None, clock: Callable[[], str] | None = None):
|
|
120
|
+
self.policy = policy or Policy()
|
|
121
|
+
self.clock = clock or now
|
|
122
|
+
self.spend = spend
|
|
123
|
+
self.memory = path in (None, ":memory:", "")
|
|
124
|
+
self.path = ":memory:" if self.memory else str(path)
|
|
125
|
+
self._lock = threading.RLock()
|
|
126
|
+
self._snap_lock = self._lock if self.memory else threading.Lock()
|
|
127
|
+
self._local = threading.local()
|
|
128
|
+
self._writer = _Writer()
|
|
129
|
+
self._snap: Optional[D.Snap] = None
|
|
130
|
+
self._verified: dict = {"sig": None, "result": None}
|
|
131
|
+
self._mem: Optional[store.Conn] = None
|
|
132
|
+
self._opened: list = []
|
|
133
|
+
with self._lock:
|
|
134
|
+
con = self._connect()
|
|
135
|
+
store.install(con)
|
|
136
|
+
|
|
137
|
+
# ── connections ─────────────────────────────────────────────────
|
|
138
|
+
def _connect(self) -> sqlite3.Connection:
|
|
139
|
+
if self.memory:
|
|
140
|
+
if self._mem is None:
|
|
141
|
+
self._mem = sqlite3.connect(":memory:", isolation_level=None, check_same_thread=False,
|
|
142
|
+
factory=store.Conn)
|
|
143
|
+
return self._mem
|
|
144
|
+
con = getattr(self._local, "con", None)
|
|
145
|
+
if con is not None:
|
|
146
|
+
return con
|
|
147
|
+
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
con = sqlite3.connect(self.path, timeout=10, isolation_level=None, check_same_thread=False,
|
|
149
|
+
factory=store.Conn)
|
|
150
|
+
self._local.con = con
|
|
151
|
+
self._opened.append(con)
|
|
152
|
+
return con
|
|
153
|
+
|
|
154
|
+
def close(self) -> None:
|
|
155
|
+
"""Close every connection this Graph opened (any thread)."""
|
|
156
|
+
for c in self._opened:
|
|
157
|
+
try:
|
|
158
|
+
c.really_close()
|
|
159
|
+
except sqlite3.Error:
|
|
160
|
+
pass
|
|
161
|
+
self._opened.clear()
|
|
162
|
+
self._local = threading.local()
|
|
163
|
+
if self._mem is not None:
|
|
164
|
+
self._mem.really_close()
|
|
165
|
+
self._mem = None
|
|
166
|
+
self._snap = None
|
|
167
|
+
|
|
168
|
+
def __enter__(self):
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, *exc):
|
|
172
|
+
self.close()
|
|
173
|
+
|
|
174
|
+
def __repr__(self) -> str:
|
|
175
|
+
return f"Graph({self.path!r})"
|
|
176
|
+
|
|
177
|
+
def writing_for(self, task: str | None) -> writing_for:
|
|
178
|
+
return writing_for(self, task)
|
|
179
|
+
|
|
180
|
+
def author(self, name: str) -> "Author":
|
|
181
|
+
"""A handle that writes as ``name`` — convenient in notebooks and scripts."""
|
|
182
|
+
return Author(self, name)
|
|
183
|
+
|
|
184
|
+
# ── the event chain ─────────────────────────────────────────────
|
|
185
|
+
def _event(self, con, actor: str, kind: str, ref: str, payload: dict, at: str) -> int:
|
|
186
|
+
task = self._writer.task
|
|
187
|
+
if task and "task" not in payload:
|
|
188
|
+
payload = {**payload, "task": task}
|
|
189
|
+
return store.append_event(con, actor, kind, ref, payload, at)
|
|
190
|
+
|
|
191
|
+
def verify(self, path: str = "") -> dict:
|
|
192
|
+
"""Recompute the hash chain and check every stored body and edge against its event. ``path``: check another
|
|
193
|
+
copy read-only (a backup) instead."""
|
|
194
|
+
if path:
|
|
195
|
+
return store.verify_file(path)
|
|
196
|
+
with self._snap_lock:
|
|
197
|
+
return store.verify_connection(self._connect())
|
|
198
|
+
|
|
199
|
+
def verify_chain(self) -> dict:
|
|
200
|
+
"""verify(), cached until the record changes."""
|
|
201
|
+
sig = self._sig()
|
|
202
|
+
if self._verified["sig"] == sig and self._verified["result"] is not None:
|
|
203
|
+
return self._verified["result"]
|
|
204
|
+
res = self.verify()
|
|
205
|
+
self._verified.update(sig=self._sig(), result=res)
|
|
206
|
+
return res
|
|
207
|
+
|
|
208
|
+
# ── the snapshot ────────────────────────────────────────────────
|
|
209
|
+
def _sig(self) -> tuple:
|
|
210
|
+
"""What changes with every commit. For a file: size, mtime and SQLite's own change counter (header bytes
|
|
211
|
+
24-27) — mtime alone can miss a commit on a coarse-clock filesystem — read without a SQLite query, which on
|
|
212
|
+
a host mount costs lock traffic. In memory: the head of the event log (every write adds an event)."""
|
|
213
|
+
if self.memory:
|
|
214
|
+
with self._lock:
|
|
215
|
+
r = self._connect().execute("SELECT MAX(seq) FROM events").fetchone()
|
|
216
|
+
return ("mem", int(r[0] or 0))
|
|
217
|
+
try:
|
|
218
|
+
st = os.stat(self.path)
|
|
219
|
+
with open(self.path, "rb") as f:
|
|
220
|
+
f.seek(24)
|
|
221
|
+
counter = f.read(4)
|
|
222
|
+
return (st.st_size, st.st_mtime_ns, counter)
|
|
223
|
+
except OSError:
|
|
224
|
+
return (0, 0, b"")
|
|
225
|
+
|
|
226
|
+
def snapshot(self) -> D.Snap:
|
|
227
|
+
"""The whole graph at its latest event. Reads never touch the database unless something was written since;
|
|
228
|
+
then only the new rows are loaded (or, after a retraction, everything)."""
|
|
229
|
+
sig = self._sig()
|
|
230
|
+
s = self._snap
|
|
231
|
+
if s is not None and s.sig == sig:
|
|
232
|
+
t = self.clock()
|
|
233
|
+
if any(v["expires"] <= t for v in s.leases.values()): # leases expire without a write
|
|
234
|
+
s.leases = {k: v for k, v in s.leases.items() if v["expires"] > t}
|
|
235
|
+
s._state.clear()
|
|
236
|
+
return s
|
|
237
|
+
with self._snap_lock:
|
|
238
|
+
if self._snap is not None and self._snap.sig == sig:
|
|
239
|
+
return self._snap
|
|
240
|
+
con = self._connect()
|
|
241
|
+
snap = self._delta(con, self._snap) if self._snap is not None else None
|
|
242
|
+
if snap is None:
|
|
243
|
+
snap = self._rebuild(con)
|
|
244
|
+
snap.sig = sig
|
|
245
|
+
self._snap = snap
|
|
246
|
+
return snap
|
|
247
|
+
|
|
248
|
+
def _head_seq(self, con) -> int:
|
|
249
|
+
r = con.execute("SELECT MAX(seq) FROM events").fetchone()
|
|
250
|
+
return int(r[0] or 0)
|
|
251
|
+
|
|
252
|
+
def _rebuild(self, con) -> D.Snap:
|
|
253
|
+
s = D.Snap(0, self.policy)
|
|
254
|
+
retracted = {r[0] for r in con.execute("SELECT edge FROM retractions")}
|
|
255
|
+
self._load_rows(con, s, retracted)
|
|
256
|
+
s.seq = max(self._head_seq(con), s.seq)
|
|
257
|
+
return s
|
|
258
|
+
|
|
259
|
+
def _load_rows(self, con, s: D.Snap, retracted: set, *, shared: bool = False) -> set:
|
|
260
|
+
"""Add every row newer than s.marks to s (all of it for a fresh Snap) and return the ids of the nodes those
|
|
261
|
+
rows touched. ``shared``: s's per-node lists still belong to an older snapshot too, so a list is copied the
|
|
262
|
+
first time this load appends to it (readers of the old snapshot never see it change) and appended to after."""
|
|
263
|
+
touched: set = set()
|
|
264
|
+
owned: set = set()
|
|
265
|
+
|
|
266
|
+
def push(table: dict, key: str, item) -> None:
|
|
267
|
+
if shared and (id(table), key) not in owned:
|
|
268
|
+
table[key] = list(table.get(key, ())) + [item]
|
|
269
|
+
owned.add((id(table), key))
|
|
270
|
+
else:
|
|
271
|
+
table[key].append(item)
|
|
272
|
+
|
|
273
|
+
con.row_factory = sqlite3.Row
|
|
274
|
+
try:
|
|
275
|
+
m = s.marks
|
|
276
|
+
for r in con.execute("SELECT rowid AS _r, * FROM nodes WHERE rowid > ? ORDER BY rowid", (m["nodes"],)):
|
|
277
|
+
row = dict(r)
|
|
278
|
+
m["nodes"] = max(m["nodes"], row.pop("_r"))
|
|
279
|
+
s.nodes[row["id"]] = row | {"v": 0, "body": {}}
|
|
280
|
+
touched.add(row["id"])
|
|
281
|
+
for r in con.execute("SELECT rowid AS _r, node, v, at, by, reason, body FROM versions WHERE rowid > ? "
|
|
282
|
+
"ORDER BY node, v", (m["versions"],)):
|
|
283
|
+
row = dict(r)
|
|
284
|
+
m["versions"] = max(m["versions"], row.pop("_r"))
|
|
285
|
+
row["body"] = json.loads(row["body"])
|
|
286
|
+
push(s.versions, row["node"], row)
|
|
287
|
+
touched.add(row["node"])
|
|
288
|
+
n = s.nodes.get(row["node"])
|
|
289
|
+
if n and row["v"] >= n["v"]:
|
|
290
|
+
s.nodes[row["node"]] = n | {"v": row["v"], "body": row["body"], "revised_at": row["at"],
|
|
291
|
+
"revised_by": row["by"]}
|
|
292
|
+
s.events.extend(dict(r) | {"payload": json.loads(r["payload"])} for r in
|
|
293
|
+
con.execute("SELECT seq, at, actor, kind, ref, payload FROM events WHERE seq > ? "
|
|
294
|
+
"ORDER BY seq", (s.seq,)))
|
|
295
|
+
if s.events:
|
|
296
|
+
s.seq = s.events[-1]["seq"]
|
|
297
|
+
for r in con.execute("SELECT * FROM edges WHERE id > ? ORDER BY id", (m["edges"],)):
|
|
298
|
+
e = dict(r) | {"data": json.loads(r["data"] or "{}")}
|
|
299
|
+
m["edges"] = max(m["edges"], e["id"])
|
|
300
|
+
if e["id"] in retracted or e["src"] not in s.nodes or e["dst"] not in s.nodes:
|
|
301
|
+
continue
|
|
302
|
+
s.edges[e["id"]] = e
|
|
303
|
+
push(s.out, e["src"], e)
|
|
304
|
+
push(s.inn, e["dst"], e)
|
|
305
|
+
touched.update((e["src"], e["dst"]))
|
|
306
|
+
for r in con.execute("SELECT rowid AS _r, * FROM withdrawals WHERE rowid > ?", (m["withdrawals"],)):
|
|
307
|
+
row = dict(r)
|
|
308
|
+
m["withdrawals"] = max(m["withdrawals"], row.pop("_r"))
|
|
309
|
+
s.withdrawn[row["node"]] = row
|
|
310
|
+
touched.add(row["node"])
|
|
311
|
+
t = self.clock()
|
|
312
|
+
for r in con.execute("SELECT * FROM leases WHERE id > ? ORDER BY id", (m["leases"],)):
|
|
313
|
+
m["leases"] = max(m["leases"], r["id"])
|
|
314
|
+
touched.add(r["node"])
|
|
315
|
+
if r["kind"] == "take":
|
|
316
|
+
s.leases[r["node"]] = dict(r)
|
|
317
|
+
elif r["kind"] == "release" and s.leases.get(r["node"], {}).get("agent") == r["agent"]:
|
|
318
|
+
s.leases.pop(r["node"], None)
|
|
319
|
+
s.leases = {k: v for k, v in s.leases.items() if v["expires"] > t}
|
|
320
|
+
finally:
|
|
321
|
+
con.row_factory = None
|
|
322
|
+
return touched
|
|
323
|
+
|
|
324
|
+
def _delta(self, con, old: D.Snap) -> Optional[D.Snap]:
|
|
325
|
+
"""The old snapshot plus what was written since. Readers holding the old one never see it change: the tables
|
|
326
|
+
are shallow copies (C speed), and a per-node list is copied only when this load appends to it — a write
|
|
327
|
+
costs what it touched, not the size of the graph. Derived standing carries over for every node the write
|
|
328
|
+
can't have affected (derive.affected_by). None (rebuild instead) when an edge was retracted: retractions
|
|
329
|
+
remove, they don't append."""
|
|
330
|
+
if con.execute("SELECT 1 FROM events WHERE seq > ? AND kind = 'retract' LIMIT 1", (old.seq,)).fetchone():
|
|
331
|
+
return None
|
|
332
|
+
s = D.Snap(old.seq, self.policy)
|
|
333
|
+
s.nodes = dict(old.nodes)
|
|
334
|
+
s.edges = dict(old.edges)
|
|
335
|
+
s.out = defaultdict(list, old.out)
|
|
336
|
+
s.inn = defaultdict(list, old.inn)
|
|
337
|
+
s.withdrawn = dict(old.withdrawn)
|
|
338
|
+
s.leases = dict(old.leases)
|
|
339
|
+
s.versions = defaultdict(list, old.versions)
|
|
340
|
+
s.events = list(old.events)
|
|
341
|
+
s.marks = dict(old.marks)
|
|
342
|
+
touched = self._load_rows(con, s, set(), shared=True)
|
|
343
|
+
if old._state and touched:
|
|
344
|
+
stale = D.affected_by(s, touched)
|
|
345
|
+
s._state = {k: v for k, v in old._state.items() if k not in stale}
|
|
346
|
+
elif not touched:
|
|
347
|
+
s._state = dict(old._state)
|
|
348
|
+
return s
|
|
349
|
+
|
|
350
|
+
def _invalidate(self) -> None:
|
|
351
|
+
self._snap = None
|
|
352
|
+
|
|
353
|
+
def _after_write(self) -> None:
|
|
354
|
+
"""From every write's finally: a failed write drops the snapshot (it may hold a staged node); a write that
|
|
355
|
+
landed leaves it — the next read loads only the new rows."""
|
|
356
|
+
if sys.exc_info()[0] is not None:
|
|
357
|
+
self._invalidate()
|
|
358
|
+
|
|
359
|
+
def state(self, nid: str) -> dict:
|
|
360
|
+
return D.state(self.snapshot(), nid)
|
|
361
|
+
|
|
362
|
+
# ── writes ──────────────────────────────────────────────────────
|
|
363
|
+
def _rate(self, con, author: str) -> None:
|
|
364
|
+
if author in self.policy.reserved:
|
|
365
|
+
return
|
|
366
|
+
since = (datetime.fromisoformat(self.clock()) - timedelta(minutes=1)).isoformat(timespec="seconds")
|
|
367
|
+
n = con.execute("SELECT COUNT(*) FROM events WHERE actor=? AND kind='create' AND at>=?",
|
|
368
|
+
(author, since)).fetchone()[0]
|
|
369
|
+
if n >= self.policy.max_creates_per_minute:
|
|
370
|
+
raise GraphError(f"rate limit: {self.policy.max_creates_per_minute} new nodes a minute")
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _code(con, ntype: str, inv: str) -> str:
|
|
374
|
+
"""The next free display code (H3, CL12 …) in an investigation — an indexed count and lookups, not a scan."""
|
|
375
|
+
prefix = TYPES[ntype]["prefix"]
|
|
376
|
+
k = con.execute("SELECT COUNT(*) FROM nodes WHERE inv=? AND type=?", (inv, ntype)).fetchone()[0] + 1
|
|
377
|
+
while con.execute("SELECT 1 FROM nodes WHERE inv=? AND code=? LIMIT 1", (inv, f"{prefix}{k}")).fetchone():
|
|
378
|
+
k += 1
|
|
379
|
+
return f"{prefix}{k}"
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _orient(s: D.Snap, src: str, rel: str, dst: str) -> tuple[str, str, str, bool]:
|
|
383
|
+
"""A link given backwards — a claim that 'supports' its result — is turned round when only the reverse is
|
|
384
|
+
allowed (the relation's meaning fixes its direction, so there is one reading)."""
|
|
385
|
+
if rel in RELS and src in s.nodes and dst in s.nodes and rel not in SAME_TYPE:
|
|
386
|
+
st, dt = s.nodes[src]["type"], s.nodes[dst]["type"]
|
|
387
|
+
srcs, dsts = RELS[rel]
|
|
388
|
+
if not (st in srcs and dt in dsts) and (dt in srcs and st in dsts):
|
|
389
|
+
return dst, rel, src, True
|
|
390
|
+
return src, rel, dst, False
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _check_link(s: D.Snap, src: str, rel: str, dst: str) -> None:
|
|
394
|
+
if rel not in RELS:
|
|
395
|
+
raise GraphError(f"unknown relation {rel!r}; relations: {', '.join(RELS)}")
|
|
396
|
+
for x in (src, dst):
|
|
397
|
+
if x not in s.nodes:
|
|
398
|
+
raise GraphError(f"no node {x}")
|
|
399
|
+
st, dt = s.nodes[src]["type"], s.nodes[dst]["type"]
|
|
400
|
+
srcs, dsts = RELS[rel]
|
|
401
|
+
if st not in srcs or dt not in dsts:
|
|
402
|
+
raise GraphError(f"{rel} can't join {st} → {dt} (allowed: {', '.join(sorted(srcs))} → "
|
|
403
|
+
f"{', '.join(sorted(dsts))})")
|
|
404
|
+
if rel in SAME_TYPE and st != dt:
|
|
405
|
+
raise GraphError(f"{rel} joins two nodes of the same type")
|
|
406
|
+
if src == dst:
|
|
407
|
+
raise GraphError("a node can't link to itself")
|
|
408
|
+
|
|
409
|
+
def _insert_edge(self, con, s: D.Snap, src: str, rel: str, dst: str, data: dict, why: str, author: str,
|
|
410
|
+
at: str) -> int:
|
|
411
|
+
P = self.policy
|
|
412
|
+
self._check_link(s, src, rel, dst)
|
|
413
|
+
data = dict(data or {})
|
|
414
|
+
why = str(why or "").strip()[:1500]
|
|
415
|
+
trusted = author in P.reserved
|
|
416
|
+
if rel in ("supports", "challenges") and len(why) < 10 and not trusted:
|
|
417
|
+
raise GraphError(f"{rel}: say why (a sentence) — what in {src} bears on {dst}")
|
|
418
|
+
if rel == "replicates" and data.get("differs") not in REPLICATION:
|
|
419
|
+
raise GraphError(f"replicates needs data.differs: one of {', '.join(REPLICATION)}")
|
|
420
|
+
if rel == "uses" and (data.get("role") or "data") not in ("data", "code", "model", "config"):
|
|
421
|
+
raise GraphError("uses needs data.role: data | code | model | config")
|
|
422
|
+
if rel == "tests" and not str(data.get("expected") or "").strip() and not trusted:
|
|
423
|
+
raise GraphError(f"tests {dst}: give data.expected — what this experiment should show if that hypothesis "
|
|
424
|
+
"is true (that is what makes it a test)")
|
|
425
|
+
if rel == "produced" and s.nodes[src]["created_by"] == P.instrument and \
|
|
426
|
+
author not in {P.instrument} | P.curators:
|
|
427
|
+
raise GraphError(f"only the {P.instrument} records what a {P.instrument} run produced")
|
|
428
|
+
src_v, dst_v = s.nodes[src]["v"], s.nodes[dst]["v"]
|
|
429
|
+
cur = con.execute("INSERT INTO edges (src, src_v, rel, dst, dst_v, data, at, by, why) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
430
|
+
(src, src_v, rel, dst, dst_v, canon(data), at, author, why))
|
|
431
|
+
eid = cur.lastrowid
|
|
432
|
+
self._event(con, author, "link", src, {"edge": eid, "rel": rel, "dst": dst, "src_v": src_v, "dst_v": dst_v,
|
|
433
|
+
"data_sha": store.sha(canon(data))}, at)
|
|
434
|
+
return eid
|
|
435
|
+
|
|
436
|
+
@staticmethod
|
|
437
|
+
def _link_spec(nid: str, l: dict) -> tuple[str, str, str]:
|
|
438
|
+
"""A link given at creation: {"rel", "to"} goes from the new node; {"rel", "from"} comes into it."""
|
|
439
|
+
rel = str(l.get("rel") or "")
|
|
440
|
+
if l.get("from"):
|
|
441
|
+
return str(l["from"]), rel, nid
|
|
442
|
+
if l.get("to"):
|
|
443
|
+
return nid, rel, str(l["to"])
|
|
444
|
+
raise GraphError(f"link {l}: give 'to' (from this node) or 'from' (into it)")
|
|
445
|
+
|
|
446
|
+
@staticmethod
|
|
447
|
+
def _support_ids(s: D.Snap, nid: str, pending: list[tuple[str, str, str]]) -> list[str]:
|
|
448
|
+
ids = [a for a, rel, b in pending if rel == "supports" and b == nid]
|
|
449
|
+
ids += [b for a, rel, b in pending if rel == "derived_from" and a == nid]
|
|
450
|
+
ids += [e["src"] for e in s.inn.get(nid, []) if e["rel"] == "supports"]
|
|
451
|
+
ids += [e["dst"] for e in s.out.get(nid, []) if e["rel"] == "derived_from"]
|
|
452
|
+
return list(dict.fromkeys(ids))
|
|
453
|
+
|
|
454
|
+
@staticmethod
|
|
455
|
+
def _version_row(con, nid: str, v: int, at: str, author: str, reason: str, body: dict) -> str:
|
|
456
|
+
c = canon(body)
|
|
457
|
+
con.execute("INSERT INTO versions (node, v, at, by, reason, body) VALUES (?,?,?,?,?,?)",
|
|
458
|
+
(nid, v, at, author, str(reason)[:500], c))
|
|
459
|
+
return store.sha(c)
|
|
460
|
+
|
|
461
|
+
@staticmethod
|
|
462
|
+
def _bind_measurements(s: D.Snap, body: dict, inv: str) -> dict:
|
|
463
|
+
"""A measurement may name its result by code (RS3): stored by id, so the binding can't drift."""
|
|
464
|
+
meas = body.get("measurements")
|
|
465
|
+
if not meas:
|
|
466
|
+
return body
|
|
467
|
+
out = []
|
|
468
|
+
for m in meas:
|
|
469
|
+
ref = str(m.get("result") or "") if isinstance(m, dict) else ""
|
|
470
|
+
if ref and ref not in s.nodes:
|
|
471
|
+
hits = [i for i, n in s.nodes.items() if n["code"].lower() == ref.lower() and n["inv"] == inv
|
|
472
|
+
and n["type"] == "result"]
|
|
473
|
+
if len(hits) == 1:
|
|
474
|
+
m = {**m, "result": hits[0]}
|
|
475
|
+
out.append(m)
|
|
476
|
+
return {**body, "measurements": out}
|
|
477
|
+
|
|
478
|
+
def _attribution(self, author: str, attribute_to: str) -> str:
|
|
479
|
+
"""Who a record is shown as by. Only the importer may attribute (to the original author of a legacy entry)."""
|
|
480
|
+
if attribute_to and author == self.policy.importer:
|
|
481
|
+
return str(attribute_to).strip()[:40] or author
|
|
482
|
+
return author
|
|
483
|
+
|
|
484
|
+
def _when(self, author: str, at: str) -> str:
|
|
485
|
+
"""The time a write is stamped with: the clock's. Only the importer brings a legacy entry in at its own date."""
|
|
486
|
+
if at and author != self.policy.importer:
|
|
487
|
+
raise GraphError(f"only the {self.policy.importer} sets a write's time; everyone else's is the clock's")
|
|
488
|
+
return str(at) if at else self.clock()
|
|
489
|
+
|
|
490
|
+
def _fixed_guard(self, s: D.Snap, author: str, nid: str) -> None:
|
|
491
|
+
inv = s.nodes.get(s.nodes[nid]["inv"]) if nid in s.nodes else None
|
|
492
|
+
if inv and author not in self.policy.reserved and (inv["body"].get("state") or "active") in ("archived",
|
|
493
|
+
"published"):
|
|
494
|
+
raise GraphError(f"{inv['body'].get('title', inv['id'])} is {inv['body'].get('state')} — its record is "
|
|
495
|
+
"fixed; build on it in a follow-up investigation (reviews and comments are still welcome)")
|
|
496
|
+
|
|
497
|
+
def _transaction(self, fn):
|
|
498
|
+
con = self._connect()
|
|
499
|
+
con.execute("BEGIN IMMEDIATE")
|
|
500
|
+
try:
|
|
501
|
+
out = fn(con)
|
|
502
|
+
con.execute("COMMIT")
|
|
503
|
+
return out
|
|
504
|
+
except BaseException:
|
|
505
|
+
con.execute("ROLLBACK")
|
|
506
|
+
raise
|
|
507
|
+
|
|
508
|
+
def create(self, *, author: str, type: str, fields: dict, inv: str = "", links: list | None = None,
|
|
509
|
+
code: str = "", node_id: str = "", reason: str = "", at: str = "", attribute_to: str = "") -> dict:
|
|
510
|
+
"""Add a node (version 1) with its links. ``links``: [{"rel", "to"} | {"rel", "from"}, "why", "data"].
|
|
511
|
+
Claims are checked (checks.claim_problems) unless a curator writes them or the investigation is non-strict.
|
|
512
|
+
Artifacts are content-addressed: the same bytes are one node."""
|
|
513
|
+
P = self.policy
|
|
514
|
+
author = str(author or "").strip()[:40]
|
|
515
|
+
if not author:
|
|
516
|
+
raise GraphError("author is required")
|
|
517
|
+
if type not in TYPES:
|
|
518
|
+
raise GraphError(f"unknown type {type!r}; types: {', '.join(TYPES)}")
|
|
519
|
+
if type == "review":
|
|
520
|
+
raise GraphError("use review() to record a review")
|
|
521
|
+
body = clean_body(type, fields)
|
|
522
|
+
links = [l for l in (links or []) if isinstance(l, dict)]
|
|
523
|
+
with self._lock:
|
|
524
|
+
try:
|
|
525
|
+
s = self.snapshot()
|
|
526
|
+
if type == "investigation":
|
|
527
|
+
inv = ""
|
|
528
|
+
elif not inv:
|
|
529
|
+
raise GraphError("inv: which investigation this belongs to (search(type='investigation') lists "
|
|
530
|
+
"them)")
|
|
531
|
+
elif inv not in s.nodes or s.nodes[inv]["type"] != "investigation":
|
|
532
|
+
raise GraphError(f"no investigation {inv}")
|
|
533
|
+
if type == "artifact":
|
|
534
|
+
node_id = "a-" + str(body["sha256"]).lower()[:16]
|
|
535
|
+
if node_id in s.nodes:
|
|
536
|
+
return self.get(node_id)
|
|
537
|
+
nid = node_id or ("inv-" + secrets.token_hex(4) if type == "investigation"
|
|
538
|
+
else "n" + secrets.token_hex(6))
|
|
539
|
+
if nid in s.nodes:
|
|
540
|
+
raise GraphError(f"node {nid} already exists")
|
|
541
|
+
at = self._when(author, at)
|
|
542
|
+
if inv and author not in P.reserved and \
|
|
543
|
+
(s.nodes[inv]["body"].get("state") or "active") in ("archived", "published"):
|
|
544
|
+
raise GraphError(f"{s.nodes[inv]['body'].get('title', inv)} is "
|
|
545
|
+
f"{s.nodes[inv]['body'].get('state')} — its record is fixed. Open a follow-up "
|
|
546
|
+
"investigation and link to what you build on; reviews and comments on it are "
|
|
547
|
+
"still welcome.")
|
|
548
|
+
who = self._attribution(author, attribute_to)
|
|
549
|
+
if type == "run" and body.get("kind") == "lab" and who != P.instrument:
|
|
550
|
+
raise GraphError(f"kind=lab is the {P.instrument}'s own record (filed by the instrument itself, "
|
|
551
|
+
"with hashes); a run you report is kind=agent")
|
|
552
|
+
staged = {"id": nid, "type": type, "code": "", "inv": inv, "created_at": at, "created_by": who,
|
|
553
|
+
"v": 1, "body": body}
|
|
554
|
+
s.nodes[nid] = staged # staged for link checks; dropped if the write fails
|
|
555
|
+
oriented = [self._orient(s, *self._link_spec(nid, l)) for l in links]
|
|
556
|
+
pending = [(a, rel, b) for a, rel, b, _f in oriented]
|
|
557
|
+
flipped = [f"{s.nodes[a].get('code') or a} -{rel}-> {s.nodes[b].get('code') or b}"
|
|
558
|
+
for a, rel, b, f in oriented if f]
|
|
559
|
+
for a, rel, b in pending:
|
|
560
|
+
self._check_link(s, a, rel, b)
|
|
561
|
+
if type == "claim":
|
|
562
|
+
body = self._bind_measurements(s, body, inv)
|
|
563
|
+
staged["body"] = body
|
|
564
|
+
if type == "claim" and author not in P.curators:
|
|
565
|
+
problems = claim_problems(s, body, self._support_ids(s, nid, pending), inv)
|
|
566
|
+
if problems:
|
|
567
|
+
raise GraphError("claim refused:\n- " + "\n- ".join(problems))
|
|
568
|
+
|
|
569
|
+
def write(con):
|
|
570
|
+
self._rate(con, author)
|
|
571
|
+
c = str(code or "").strip()[:40] or self._code(con, type, inv)
|
|
572
|
+
staged["code"] = c
|
|
573
|
+
con.execute("INSERT INTO nodes (id, type, code, inv, created_at, created_by) VALUES (?,?,?,?,?,?)",
|
|
574
|
+
(nid, type, c, inv, at, who))
|
|
575
|
+
sha = self._version_row(con, nid, 1, at, author, reason, body)
|
|
576
|
+
self._event(con, author, "create", nid, {"type": type, "code": c, "inv": inv, "v": 1,
|
|
577
|
+
"body_sha": sha}, at)
|
|
578
|
+
for (a, rel, b), l in zip(pending, links):
|
|
579
|
+
self._insert_edge(con, s, a, rel, b, l.get("data") or {}, l.get("why") or "", author, at)
|
|
580
|
+
self._transaction(write)
|
|
581
|
+
finally:
|
|
582
|
+
# the staged node lives in the cached snapshot: a failed write drops it; after a commit the next
|
|
583
|
+
# read loads the committed rows over it
|
|
584
|
+
self._after_write()
|
|
585
|
+
out = self.get(nid)
|
|
586
|
+
if flipped:
|
|
587
|
+
out["notes"] = [f"link turned round to the direction the relation allows: {x}" for x in flipped]
|
|
588
|
+
return out
|
|
589
|
+
|
|
590
|
+
def _may_revise(self, s: D.Snap, author: str, n: dict) -> bool:
|
|
591
|
+
return author in {n["created_by"]} | self.policy.curators or author in D.board(s, n["id"])
|
|
592
|
+
|
|
593
|
+
def revise(self, *, author: str, node: str, fields: dict, reason: str) -> dict:
|
|
594
|
+
"""A new version: the given fields replace the old ones (others carry over). Reviews of the old version stay
|
|
595
|
+
on record but no longer count as a check of this one. Only the author, a reviewer or a curator revises;
|
|
596
|
+
anyone else challenges it, or writes a node that supersedes it."""
|
|
597
|
+
P = self.policy
|
|
598
|
+
if len(str(reason or "").strip()) < 10:
|
|
599
|
+
raise GraphError("reason: why this changes (a sentence)")
|
|
600
|
+
with self._lock:
|
|
601
|
+
try:
|
|
602
|
+
s = self.snapshot()
|
|
603
|
+
n = s.nodes.get(node)
|
|
604
|
+
if not n:
|
|
605
|
+
raise GraphError(f"no node {node}")
|
|
606
|
+
if n["type"] in ("artifact", "review"):
|
|
607
|
+
raise GraphError(f"a {n['type']} can't be revised — add a new one")
|
|
608
|
+
if n["created_by"] == P.instrument and author not in (P.instrument, P.operator):
|
|
609
|
+
raise GraphError(f"a {P.instrument}-recorded run is the {P.instrument}'s record; add a result or "
|
|
610
|
+
"a review instead")
|
|
611
|
+
self._fixed_guard(s, author, node)
|
|
612
|
+
if not self._may_revise(s, author, n):
|
|
613
|
+
raise GraphError(f"only {n['created_by']} (its author), a reviewer or the {P.operator} revises "
|
|
614
|
+
f"{n['code']}; challenge it, or propose a node that supersedes it")
|
|
615
|
+
body = clean_body(n["type"], {**n["body"], **(fields or {})})
|
|
616
|
+
if n["type"] == "claim":
|
|
617
|
+
body = self._bind_measurements(s, body, n["inv"])
|
|
618
|
+
if n["type"] == "run" and body.get("kind") != n["body"].get("kind") and author != P.operator:
|
|
619
|
+
raise GraphError("a run's kind (lab / agent / external) is where its record came from; it doesn't "
|
|
620
|
+
"change")
|
|
621
|
+
if n["type"] == "claim" and author not in P.curators:
|
|
622
|
+
problems = claim_problems(s, body, self._support_ids(s, node, []), n["inv"])
|
|
623
|
+
if problems:
|
|
624
|
+
raise GraphError("revision refused:\n- " + "\n- ".join(problems))
|
|
625
|
+
at = self.clock()
|
|
626
|
+
|
|
627
|
+
def write(con):
|
|
628
|
+
v = con.execute("SELECT MAX(v) FROM versions WHERE node=?", (node,)).fetchone()[0] + 1
|
|
629
|
+
sha = self._version_row(con, node, v, at, author, reason, body)
|
|
630
|
+
self._event(con, author, "revise", node, {"v": v, "reason": str(reason)[:300], "body_sha": sha}, at)
|
|
631
|
+
self._transaction(write)
|
|
632
|
+
finally:
|
|
633
|
+
self._after_write()
|
|
634
|
+
return self.get(node)
|
|
635
|
+
|
|
636
|
+
def link(self, *, author: str, src: str, rel: str, dst: str, why: str = "", data: dict | None = None) -> dict:
|
|
637
|
+
if rel == "reviews":
|
|
638
|
+
raise GraphError("use review() — a review is its own node")
|
|
639
|
+
with self._lock:
|
|
640
|
+
try:
|
|
641
|
+
s = self.snapshot()
|
|
642
|
+
src, rel, dst, flipped = self._orient(s, src, rel, dst)
|
|
643
|
+
if src in s.nodes:
|
|
644
|
+
self._fixed_guard(s, author, src)
|
|
645
|
+
eid = self._transaction(lambda con: self._insert_edge(con, s, src, rel, dst, data or {}, why, author,
|
|
646
|
+
self.clock()))
|
|
647
|
+
finally:
|
|
648
|
+
self._after_write()
|
|
649
|
+
return {"edge": eid, "src": src, "rel": rel, "dst": dst, "flipped": flipped}
|
|
650
|
+
|
|
651
|
+
def retract(self, *, author: str, edge: int, why: str) -> dict:
|
|
652
|
+
if len(str(why or "").strip()) < 10:
|
|
653
|
+
raise GraphError("why: say why this link no longer holds")
|
|
654
|
+
with self._lock:
|
|
655
|
+
try:
|
|
656
|
+
s = self.snapshot()
|
|
657
|
+
e = s.edges.get(int(edge))
|
|
658
|
+
if not e:
|
|
659
|
+
raise GraphError(f"no live edge {edge}")
|
|
660
|
+
if author not in (e["by"], self.policy.operator) and author not in D.board(s, e["src"]):
|
|
661
|
+
raise GraphError(f"only the link's author, a reviewer or the {self.policy.operator} retracts a link")
|
|
662
|
+
at = self.clock()
|
|
663
|
+
|
|
664
|
+
def write(con):
|
|
665
|
+
con.execute("INSERT INTO retractions (edge, at, by, why) VALUES (?,?,?,?)",
|
|
666
|
+
(int(edge), at, author, why[:1000]))
|
|
667
|
+
self._event(con, author, "retract", e["src"], {"edge": int(edge)}, at)
|
|
668
|
+
self._transaction(write)
|
|
669
|
+
finally:
|
|
670
|
+
self._after_write()
|
|
671
|
+
return {"retracted": int(edge)}
|
|
672
|
+
|
|
673
|
+
def withdraw(self, *, author: str, node: str, why: str, at: str = "", attribute_to: str = "") -> dict:
|
|
674
|
+
if len(str(why or "").strip()) < 10:
|
|
675
|
+
raise GraphError("why: say why it is withdrawn")
|
|
676
|
+
with self._lock:
|
|
677
|
+
try:
|
|
678
|
+
s = self.snapshot()
|
|
679
|
+
n = s.nodes.get(node)
|
|
680
|
+
if not n:
|
|
681
|
+
raise GraphError(f"no node {node}")
|
|
682
|
+
if node in s.withdrawn:
|
|
683
|
+
raise GraphError(f"{node} is already withdrawn")
|
|
684
|
+
if author not in (n["created_by"], self.policy.operator) and author not in D.board(s, node):
|
|
685
|
+
raise GraphError(f"only its author, a reviewer or the {self.policy.operator} withdraws a node")
|
|
686
|
+
at = self._when(author, at)
|
|
687
|
+
|
|
688
|
+
def write(con):
|
|
689
|
+
con.execute("INSERT INTO withdrawals (node, at, by, why) VALUES (?,?,?,?)",
|
|
690
|
+
(node, at, self._attribution(author, attribute_to), why[:1000]))
|
|
691
|
+
self._event(con, author, "withdraw", node, {"why": why[:300]}, at)
|
|
692
|
+
self._transaction(write)
|
|
693
|
+
finally:
|
|
694
|
+
self._after_write()
|
|
695
|
+
return self.get(node)
|
|
696
|
+
|
|
697
|
+
def review(self, *, author: str, target: str, verdict: str, method: str, notes: str, links: list | None = None,
|
|
698
|
+
at: str = "", attribute_to: str = "") -> dict:
|
|
699
|
+
"""A review of a node's CURRENT version. Board members' verdicts count; anyone else's is recorded as a
|
|
700
|
+
comment. Nobody reviews their own work officially."""
|
|
701
|
+
P = self.policy
|
|
702
|
+
with self._lock:
|
|
703
|
+
try:
|
|
704
|
+
s = self.snapshot()
|
|
705
|
+
n = s.nodes.get(target)
|
|
706
|
+
if not n:
|
|
707
|
+
raise GraphError(f"no node {target}")
|
|
708
|
+
if n["type"] not in RELS["reviews"][1]:
|
|
709
|
+
raise GraphError(f"a {n['type']} isn't reviewed")
|
|
710
|
+
official = author in D.board(s, target) and verdict != "comment"
|
|
711
|
+
if official and author == n["created_by"] and author not in P.curators:
|
|
712
|
+
who = ", ".join(sorted(D.board(s, target) - P.curators - {author})[:6]) or "another reviewer"
|
|
713
|
+
raise GraphError(f"you can't review your own work — ask a reviewer ({who})")
|
|
714
|
+
if verdict not in VERDICTS:
|
|
715
|
+
raise GraphError(f"verdict must be one of {', '.join(sorted(VERDICTS))}")
|
|
716
|
+
body = clean_body("review", {"verdict": verdict if official or verdict == "comment" else "comment",
|
|
717
|
+
"method": method, "notes": notes, "official": official})
|
|
718
|
+
nid = "n" + secrets.token_hex(6)
|
|
719
|
+
at = self._when(author, at)
|
|
720
|
+
|
|
721
|
+
def write(con):
|
|
722
|
+
c = self._code(con, "review", n["inv"])
|
|
723
|
+
con.execute("INSERT INTO nodes (id, type, code, inv, created_at, created_by) VALUES (?,?,?,?,?,?)",
|
|
724
|
+
(nid, "review", c, n["inv"], at, self._attribution(author, attribute_to)))
|
|
725
|
+
sha = self._version_row(con, nid, 1, at, author, "", body)
|
|
726
|
+
self._event(con, author, "review", target, {"review": nid, "verdict": body["verdict"],
|
|
727
|
+
"on_v": n["v"], "official": official,
|
|
728
|
+
"body_sha": sha}, at)
|
|
729
|
+
cur = con.execute("INSERT INTO edges (src, src_v, rel, dst, dst_v, data, at, by, why) "
|
|
730
|
+
"VALUES (?,?,?,?,?,?,?,?,?)", (nid, 1, "reviews", target, n["v"], "{}", at,
|
|
731
|
+
author, ""))
|
|
732
|
+
self._event(con, author, "link", nid, {"edge": cur.lastrowid, "rel": "reviews", "dst": target,
|
|
733
|
+
"src_v": 1, "dst_v": n["v"],
|
|
734
|
+
"data_sha": store.sha("{}")}, at)
|
|
735
|
+
self._transaction(write)
|
|
736
|
+
finally:
|
|
737
|
+
self._after_write()
|
|
738
|
+
return self.get(target)
|
|
739
|
+
|
|
740
|
+
def lease(self, *, author: str, node: str, task: str = "", minutes: int = 240, release: bool = False,
|
|
741
|
+
note: str = "") -> dict:
|
|
742
|
+
"""Take (or release) a piece of work — an experiment, a review, a question — so others see who is on it."""
|
|
743
|
+
with self._lock:
|
|
744
|
+
try:
|
|
745
|
+
s = self.snapshot()
|
|
746
|
+
if node not in s.nodes:
|
|
747
|
+
raise GraphError(f"no node {node}")
|
|
748
|
+
cur = s.leases.get(node)
|
|
749
|
+
at = self.clock()
|
|
750
|
+
if release:
|
|
751
|
+
if not cur or cur["agent"] != author:
|
|
752
|
+
raise GraphError("you don't hold a lease on it")
|
|
753
|
+
kind, exp = "release", ""
|
|
754
|
+
else:
|
|
755
|
+
if cur and cur["agent"] != author:
|
|
756
|
+
raise GraphError(f"{cur['agent']} is on it until {cur['expires']} (task {cur['task'] or '—'})"
|
|
757
|
+
" — pick another, or coordinate with them")
|
|
758
|
+
kind = "take"
|
|
759
|
+
exp = (datetime.fromisoformat(at) + timedelta(minutes=max(15, min(int(minutes), 1440)))
|
|
760
|
+
).isoformat(timespec="seconds")
|
|
761
|
+
|
|
762
|
+
def write(con):
|
|
763
|
+
con.execute("INSERT INTO leases (node, agent, task, kind, at, expires, note) VALUES (?,?,?,?,?,?,?)",
|
|
764
|
+
(node, author, str(task)[:80], kind, at, exp, str(note)[:300]))
|
|
765
|
+
self._event(con, author, "lease_" + kind, node, {"task": str(task)[:80], "expires": exp}, at)
|
|
766
|
+
self._transaction(write)
|
|
767
|
+
finally:
|
|
768
|
+
self._after_write()
|
|
769
|
+
return self.get(node)
|
|
770
|
+
|
|
771
|
+
# ── reads ───────────────────────────────────────────────────────
|
|
772
|
+
@staticmethod
|
|
773
|
+
def brief(s: D.Snap, nid: str) -> dict:
|
|
774
|
+
n = s.nodes[nid]
|
|
775
|
+
b = n["body"]
|
|
776
|
+
title = (b.get("statement") or b.get("title") or b.get("text") or b.get("aim") or b.get("summary")
|
|
777
|
+
or b.get("choice") or b.get("citation") or b.get("name") or b.get("path") or b.get("verdict") or "")
|
|
778
|
+
return {"id": nid, "type": n["type"], "code": n["code"], "inv": n["inv"], "title": str(title)[:300],
|
|
779
|
+
"kind": b.get("kind") or b.get("verdict") or "", "v": n["v"], "by": n["created_by"],
|
|
780
|
+
"at": n["created_at"], "state": D.state(s, nid)}
|
|
781
|
+
|
|
782
|
+
@staticmethod
|
|
783
|
+
def edge_out(e: dict) -> dict:
|
|
784
|
+
return {k: e[k] for k in ("id", "src", "src_v", "rel", "dst", "dst_v", "data", "at", "by", "why")}
|
|
785
|
+
|
|
786
|
+
def get(self, nid: str, history: bool = False) -> dict:
|
|
787
|
+
s = self.snapshot()
|
|
788
|
+
if nid not in s.nodes:
|
|
789
|
+
raise KeyError(nid)
|
|
790
|
+
n = s.nodes[nid]
|
|
791
|
+
out = self.brief(s, nid) | {"body": n["body"], "out": [self.edge_out(e) for e in s.out.get(nid, [])],
|
|
792
|
+
"in": [self.edge_out(e) for e in s.inn.get(nid, [])]}
|
|
793
|
+
if nid in s.withdrawn:
|
|
794
|
+
out["withdrawn"] = s.withdrawn[nid]
|
|
795
|
+
if history:
|
|
796
|
+
out["versions"] = list(s.versions.get(nid, []))
|
|
797
|
+
return out
|
|
798
|
+
|
|
799
|
+
def resolve(self, ref: str, inv: str = "") -> str:
|
|
800
|
+
"""A node id from an id or a display code (CL3, H2, X4 …), within an investigation when given."""
|
|
801
|
+
ref = str(ref or "").strip()
|
|
802
|
+
s = self.snapshot()
|
|
803
|
+
if ref in s.nodes:
|
|
804
|
+
return ref
|
|
805
|
+
hits = [i for i, n in s.nodes.items() if n["code"].lower() == ref.lower() and (not inv or n["inv"] == inv)]
|
|
806
|
+
if len(hits) == 1:
|
|
807
|
+
return hits[0]
|
|
808
|
+
if not hits:
|
|
809
|
+
raise KeyError(f"no node {ref!r}" + (f" in {inv}" if inv else ""))
|
|
810
|
+
raise GraphError(f"{ref!r} is ambiguous: " + ", ".join(f"{h} ({s.nodes[h]['inv']})" for h in hits[:6])
|
|
811
|
+
+ " — give the id or the investigation")
|
|
812
|
+
|
|
813
|
+
def investigations(self) -> list[dict]:
|
|
814
|
+
s = self.snapshot()
|
|
815
|
+
counts: dict[str, dict] = defaultdict(lambda: defaultdict(int))
|
|
816
|
+
for n in s.nodes.values():
|
|
817
|
+
if n["inv"]:
|
|
818
|
+
counts[n["inv"]][n["type"]] += 1
|
|
819
|
+
return [self.brief(s, i) | {"counts": dict(counts[i]), "body": s.nodes[i]["body"]}
|
|
820
|
+
for i, n in s.nodes.items() if n["type"] == "investigation"]
|
|
821
|
+
|
|
822
|
+
def subgraph(self, inv: str = "", types: list | None = None, limit: int = 5000) -> dict:
|
|
823
|
+
"""Every node of an investigation (or all) with derived state, and the live edges among them."""
|
|
824
|
+
s = self.snapshot()
|
|
825
|
+
ids = [i for i, n in s.nodes.items() if (not inv or n["inv"] == inv or i == inv)
|
|
826
|
+
and (not types or n["type"] in types)][:limit]
|
|
827
|
+
idset = set(ids)
|
|
828
|
+
return {"seq": s.seq, "nodes": [self.brief(s, i) for i in ids],
|
|
829
|
+
"edges": [self.edge_out(e) for e in s.edges.values() if e["src"] in idset and e["dst"] in idset]}
|
|
830
|
+
|
|
831
|
+
def neighborhood(self, nid: str, depth: int = 1, limit: int = 400) -> dict:
|
|
832
|
+
s = self.snapshot()
|
|
833
|
+
if nid not in s.nodes:
|
|
834
|
+
raise KeyError(nid)
|
|
835
|
+
seen, frontier = {nid}, [nid]
|
|
836
|
+
for _ in range(max(1, min(int(depth), 4))):
|
|
837
|
+
nxt = []
|
|
838
|
+
for x in frontier:
|
|
839
|
+
for e in s.out.get(x, []) + s.inn.get(x, []):
|
|
840
|
+
y = e["dst"] if e["src"] == x else e["src"]
|
|
841
|
+
if y not in seen and len(seen) < limit:
|
|
842
|
+
seen.add(y)
|
|
843
|
+
nxt.append(y)
|
|
844
|
+
frontier = nxt
|
|
845
|
+
return {"seq": s.seq, "center": nid, "nodes": [self.brief(s, i) for i in seen],
|
|
846
|
+
"edges": [self.edge_out(e) for e in s.edges.values() if e["src"] in seen and e["dst"] in seen]}
|
|
847
|
+
|
|
848
|
+
def why(self, nid: str) -> dict:
|
|
849
|
+
"""Upstream: what this rests on and what supports it, down to runs, artifacts and sources."""
|
|
850
|
+
s = self.snapshot()
|
|
851
|
+
if nid not in s.nodes:
|
|
852
|
+
raise KeyError(nid)
|
|
853
|
+
# Out-edges whose target is something x rests on; in-edges whose source backs x. An out-going `supports`
|
|
854
|
+
# or `produced` points downstream (x supports y → y rests on x): that's impact, not why.
|
|
855
|
+
out_rels = ("derived_from", "requires", "based_on", "executes", "uses", "cites", "tests")
|
|
856
|
+
in_rels = ("supports", "produced")
|
|
857
|
+
seen, frontier, edges = {nid}, [nid], []
|
|
858
|
+
while frontier and len(seen) < 300:
|
|
859
|
+
nxt = []
|
|
860
|
+
for x in frontier:
|
|
861
|
+
ups = [(e, e["dst"]) for e in s.out.get(x, []) if e["rel"] in out_rels] + \
|
|
862
|
+
[(e, e["src"]) for e in s.inn.get(x, []) if e["rel"] in in_rels]
|
|
863
|
+
for e, y in ups:
|
|
864
|
+
edges.append(e)
|
|
865
|
+
if y not in seen:
|
|
866
|
+
seen.add(y)
|
|
867
|
+
nxt.append(y)
|
|
868
|
+
frontier = nxt
|
|
869
|
+
return {"center": nid, "nodes": [self.brief(s, i) for i in seen], "edges": [self.edge_out(e) for e in edges]}
|
|
870
|
+
|
|
871
|
+
def impact(self, nid: str) -> dict:
|
|
872
|
+
"""Downstream: everything that rests on this node, directly or through a chain."""
|
|
873
|
+
s = self.snapshot()
|
|
874
|
+
if nid not in s.nodes:
|
|
875
|
+
raise KeyError(nid)
|
|
876
|
+
dep = D.dependents(s, nid)
|
|
877
|
+
ids = {nid} | {d for d, _r, _x in dep}
|
|
878
|
+
return {"center": nid, "dependents": [{"node": self.brief(s, d), "rel": r, "depth": k} for d, r, k in dep],
|
|
879
|
+
"edges": [self.edge_out(e) for e in s.edges.values() if e["src"] in ids and e["dst"] in ids]}
|
|
880
|
+
|
|
881
|
+
def search(self, text: str = "", type: str = "", inv: str = "", limit: int = 50) -> list[dict]:
|
|
882
|
+
s = self.snapshot()
|
|
883
|
+
q = (text or "").lower().strip()
|
|
884
|
+
out = []
|
|
885
|
+
for i, n in s.nodes.items():
|
|
886
|
+
if type and n["type"] != type:
|
|
887
|
+
continue
|
|
888
|
+
if inv and n["inv"] != inv and i != inv:
|
|
889
|
+
continue
|
|
890
|
+
if q and q not in (n["code"].lower() + " " + canon(n["body"]).lower()) and q != i:
|
|
891
|
+
continue
|
|
892
|
+
out.append(self.brief(s, i))
|
|
893
|
+
if len(out) >= limit:
|
|
894
|
+
break
|
|
895
|
+
return out
|
|
896
|
+
|
|
897
|
+
def events(self, after: int = 0, limit: int = 500, inv: str = "") -> dict:
|
|
898
|
+
s = self.snapshot()
|
|
899
|
+
rows = [e for e in s.events if e["seq"] > int(after)]
|
|
900
|
+
if inv:
|
|
901
|
+
rows = [r for r in rows if r["ref"] == inv or s.nodes.get(r["ref"], {}).get("inv") == inv]
|
|
902
|
+
return {"head": s.seq, "events": rows[: max(1, min(int(limit), 5000))]}
|
|
903
|
+
|
|
904
|
+
def stats(self) -> dict:
|
|
905
|
+
s = self.snapshot()
|
|
906
|
+
by: dict[str, int] = defaultdict(int)
|
|
907
|
+
for n in s.nodes.values():
|
|
908
|
+
by[n["type"]] += 1
|
|
909
|
+
return {"seq": s.seq, "nodes": len(s.nodes), "edges": len(s.edges), "by_type": dict(by),
|
|
910
|
+
"leases": len(s.leases)}
|
|
911
|
+
|
|
912
|
+
# ── method & coordination (plan.py) ─────────────────────────────
|
|
913
|
+
def next_experiments(self, inv: str, limit: int = 10) -> dict:
|
|
914
|
+
from . import plan
|
|
915
|
+
return plan.next_experiments(self, inv, limit)
|
|
916
|
+
|
|
917
|
+
def work_map(self, inv: str = "") -> dict:
|
|
918
|
+
from . import plan
|
|
919
|
+
return plan.work_map(self, inv)
|
|
920
|
+
|
|
921
|
+
def duplicates(self, inv: str) -> list[list[str]]:
|
|
922
|
+
from . import plan
|
|
923
|
+
return plan.duplicates(self.snapshot(), inv)
|
|
924
|
+
|
|
925
|
+
def digest(self, since: str = "", inv: str = "") -> dict:
|
|
926
|
+
from . import plan
|
|
927
|
+
return plan.digest(self, since, inv)
|
|
928
|
+
|
|
929
|
+
def cost(self, inv: str, nid: str = "") -> dict:
|
|
930
|
+
from . import plan
|
|
931
|
+
return plan.cost(self, inv, nid)
|
|
932
|
+
|
|
933
|
+
def report(self, inv: str) -> dict:
|
|
934
|
+
from . import plan
|
|
935
|
+
return plan.report(self, inv)
|
|
936
|
+
|
|
937
|
+
# ── portable bundles (bundle.py) ────────────────────────────────
|
|
938
|
+
def export(self, inv: str = "", *, derived: bool = True) -> dict:
|
|
939
|
+
"""A portable, verifiable bundle of the whole graph (or one investigation) — see bundle.py."""
|
|
940
|
+
from . import bundle
|
|
941
|
+
return bundle.export(self, inv, derived=derived)
|
|
942
|
+
|
|
943
|
+
@classmethod
|
|
944
|
+
def from_bundle(cls, b: dict, path: str | os.PathLike | None = None, *, policy: Policy | None = None) -> "Graph":
|
|
945
|
+
"""A graph rebuilt row for row from a complete bundle (in memory unless ``path`` is given). It verifies
|
|
946
|
+
exactly as the original did; refused if the bundle doesn't verify."""
|
|
947
|
+
from . import bundle
|
|
948
|
+
g = cls(path, policy=policy or Policy.from_dict(b.get("policy") or {}))
|
|
949
|
+
bundle.load(g, b)
|
|
950
|
+
return g
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
class Author:
|
|
954
|
+
"""Writes as one author. ``me = g.author("ada"); h = me.add("hypothesis", inv, statement=…, predictions=[…])``"""
|
|
955
|
+
|
|
956
|
+
def __init__(self, graph: Graph, name: str):
|
|
957
|
+
self.graph, self.name = graph, name
|
|
958
|
+
|
|
959
|
+
def __repr__(self) -> str:
|
|
960
|
+
return f"Author({self.name!r})"
|
|
961
|
+
|
|
962
|
+
def investigation(self, title: str, objective: str, **fields) -> dict:
|
|
963
|
+
return self.graph.create(author=self.name, type="investigation",
|
|
964
|
+
fields={"title": title, "objective": objective, **fields})
|
|
965
|
+
|
|
966
|
+
def add(self, type: str, inv: str = "", links: list | None = None, **fields) -> dict:
|
|
967
|
+
return self.graph.create(author=self.name, type=type, inv=inv, links=links, fields=fields)
|
|
968
|
+
|
|
969
|
+
def link(self, src: str, rel: str, dst: str, why: str = "", **data) -> dict:
|
|
970
|
+
return self.graph.link(author=self.name, src=src, rel=rel, dst=dst, why=why, data=data)
|
|
971
|
+
|
|
972
|
+
def review(self, target: str, verdict: str, method: str, notes: str) -> dict:
|
|
973
|
+
return self.graph.review(author=self.name, target=target, verdict=verdict, method=method, notes=notes)
|
|
974
|
+
|
|
975
|
+
def revise(self, node: str, reason: str, **fields) -> dict:
|
|
976
|
+
return self.graph.revise(author=self.name, node=node, fields=fields, reason=reason)
|
|
977
|
+
|
|
978
|
+
def withdraw(self, node: str, why: str) -> dict:
|
|
979
|
+
return self.graph.withdraw(author=self.name, node=node, why=why)
|
|
980
|
+
|
|
981
|
+
def take(self, node: str, task: str = "", minutes: int = 240) -> dict:
|
|
982
|
+
return self.graph.lease(author=self.name, node=node, task=task, minutes=minutes)
|
|
983
|
+
|
|
984
|
+
def release(self, node: str) -> dict:
|
|
985
|
+
return self.graph.lease(author=self.name, node=node, release=True)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def file_sha256(path: str | os.PathLike) -> dict:
|
|
989
|
+
"""sha256 of a file — the whole file, or past 256 MB its first and last 8 MB plus its size (marked partial)."""
|
|
990
|
+
q = Path(path)
|
|
991
|
+
size = q.stat().st_size
|
|
992
|
+
h = hashlib.sha256()
|
|
993
|
+
with q.open("rb") as f:
|
|
994
|
+
if size <= 256 * 1024 * 1024:
|
|
995
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
996
|
+
h.update(chunk)
|
|
997
|
+
return {"path": str(q), "sha256": h.hexdigest(), "size": size}
|
|
998
|
+
h.update(f.read(8 << 20))
|
|
999
|
+
f.seek(size - (8 << 20))
|
|
1000
|
+
h.update(f.read(8 << 20))
|
|
1001
|
+
h.update(str(size).encode())
|
|
1002
|
+
return {"path": str(q), "sha256": h.hexdigest(), "size": size, "partial": "first+last 8 MB + size"}
|