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