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 ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ HypGraph — a research record that can't flatter itself.
3
+
4
+ Don't believe the hype. Test the hyp.
5
+
6
+ >>> from hypgraph import Graph, Policy
7
+ >>> g = Graph(policy=Policy(reviewers={"ada"})) # in memory; Graph("study.hypgraph") for a file
8
+ >>> me = g.author("sam")
9
+ >>> inv = me.investigation("Plant growth", "Find out whether light colour changes how fast cress grows.")["id"]
10
+ >>> h = me.add("hypothesis", inv, statement="Blue light makes cress grow faster than red light.",
11
+ ... predictions=["Blue-lit trays are taller after 7 days."])
12
+ >>> h["state"]["sentence"]
13
+ 'active hypothesis · unchecked · no live support · unverified report · no external replication'
14
+
15
+ Standing is derived on every read, never stored; every write is an event in a SHA-256 hash chain; claims carry
16
+ their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
17
+ """
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ from .bundle import verify_bundle # noqa: E402
22
+ from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
23
+ from .policy import Policy # noqa: E402
24
+ from .schema import FORMAT, describe # noqa: E402
25
+ from .store import verify_file # noqa: E402
26
+
27
+ __all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe",
28
+ "FORMAT", "__version__"]
hypgraph/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
hypgraph/bundle.py ADDED
@@ -0,0 +1,216 @@
1
+ """
2
+ Bundles: a record you can hand to someone else, who can check it without trusting you.
3
+
4
+ ``export()`` writes the whole graph; ``export(inv)`` one investigation. Every event is included as the **exact string
5
+ its hash was taken over**, and every version body and edge's data as the exact string stored, so verifying is just
6
+ SHA-256 over strings plus a few lookups — any language can do it (the site does it in the browser with WebCrypto).
7
+
8
+ {"format": "hypgraph/1-bundle", "complete": true, "scope": {...}, "policy": {...},
9
+ "events": [{"seq", "hash", "body"}], # body = canonical JSON of {at, actor, kind, ref, payload, prev}
10
+ "nodes": [{"id", "type", "code", "inv", "created_at", "created_by"}],
11
+ "versions": [{"node", "v", "at", "by", "reason", "body"}], # body = the stored canonical string
12
+ "edges": [{"id", "src", "src_v", "rel", "dst", "dst_v", "data", "at", "by", "why"}], # data likewise
13
+ "withdrawals": [...], "retractions": [...], "leases": [...],
14
+ "derived": {...}} # standing at export time, for viewers; never trusted
15
+
16
+ A scoped bundle (one investigation out of a shared graph) redacts the events outside it to {seq, hash, prev}: their
17
+ content can't be checked, so a change hidden among them (a withdrawal, say) would not show. ``verify_bundle`` says so
18
+ (``complete: false``). To publish a study, keep it in a graph of its own and export all of it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ from . import __version__
28
+ from .store import GENESIS, event_body, now
29
+
30
+ if TYPE_CHECKING:
31
+ from .graph import Graph
32
+
33
+ BUNDLE_FORMAT = "hypgraph/1-bundle"
34
+
35
+
36
+ def _sha(s: str) -> str:
37
+ return hashlib.sha256(s.encode()).hexdigest()
38
+
39
+
40
+ def export(g: "Graph", inv: str = "", *, derived: bool = True) -> dict:
41
+ with g._snap_lock:
42
+ con = g._connect()
43
+ rows = lambda q, *a: [dict(zip([c[0] for c in cur.description], r)) # noqa: E731
44
+ for cur in [con.execute(q, a)] for r in cur.fetchall()]
45
+ nodes = rows("SELECT id, type, code, inv, created_at, created_by FROM nodes ORDER BY rowid")
46
+ if inv:
47
+ if not any(n["id"] == inv and n["type"] == "investigation" for n in nodes):
48
+ raise KeyError(inv)
49
+ nodes = [n for n in nodes if n["id"] == inv or n["inv"] == inv]
50
+ ids = {n["id"] for n in nodes}
51
+ versions = [v for v in rows("SELECT node, v, at, by, reason, body FROM versions ORDER BY rowid")
52
+ if v["node"] in ids]
53
+ edges = [e for e in rows("SELECT id, src, src_v, rel, dst, dst_v, data, at, by, why FROM edges ORDER BY id")
54
+ if e["src"] in ids and e["dst"] in ids]
55
+ eids = {e["id"] for e in edges}
56
+ withdrawals = [w for w in rows("SELECT node, at, by, why FROM withdrawals ORDER BY rowid") if w["node"] in ids]
57
+ retractions = [r for r in rows("SELECT edge, at, by, why FROM retractions ORDER BY rowid") if r["edge"] in eids]
58
+ leases = [x for x in rows("SELECT id, node, agent, task, kind, at, expires, note FROM leases ORDER BY id")
59
+ if x["node"] in ids]
60
+ events, redacted = [], 0
61
+ for r in rows("SELECT seq, at, actor, kind, ref, payload, prev, hash FROM events ORDER BY seq"):
62
+ p = json.loads(r["payload"])
63
+ mine = not inv or _event_in(r["kind"], r["ref"], p, ids, eids)
64
+ if mine:
65
+ events.append({"seq": r["seq"], "hash": r["hash"],
66
+ "body": event_body(r["at"], r["actor"], r["kind"], r["ref"], p, r["prev"])})
67
+ else:
68
+ redacted += 1
69
+ events.append({"seq": r["seq"], "hash": r["hash"], "prev": r["prev"], "redacted": True})
70
+ title = next((json.loads(v["body"]).get("title") for v in versions if v["node"] == inv), "") if inv else ""
71
+ out: dict[str, Any] = {
72
+ "format": BUNDLE_FORMAT, "generator": f"hypgraph {__version__}",
73
+ "exported_at": now(),
74
+ "complete": redacted == 0, "redacted_events": redacted,
75
+ "scope": {"inv": inv, "title": title} if inv else {"inv": "", "title": "whole graph"},
76
+ "policy": g.policy.to_dict(), "head": events[-1]["hash"] if events else GENESIS,
77
+ "events": events, "nodes": nodes, "versions": versions, "edges": edges,
78
+ "withdrawals": withdrawals, "retractions": retractions, "leases": leases,
79
+ }
80
+ if derived:
81
+ out["derived"] = derive_views(g, [inv] if inv else [n["id"] for n in nodes if n["type"] == "investigation"])
82
+ return out
83
+
84
+
85
+ def _event_in(kind: str, ref: str, p: dict, ids: set, eids: set) -> bool:
86
+ if kind in ("link", "retract"):
87
+ return int(p.get("edge", -1)) in eids or (kind == "link" and ref in ids and p.get("dst") in ids)
88
+ return ref in ids
89
+
90
+
91
+ def derive_views(g: "Graph", invs: list[str]) -> dict:
92
+ """What a viewer needs without running the engine: standing of every node and the per-investigation views."""
93
+ views = {"investigations": [i for i in g.investigations() if i["id"] in invs], "stats": g.stats(), "by_inv": {}}
94
+ for i in invs:
95
+ views["by_inv"][i] = {"subgraph": g.subgraph(i), "work": g.work_map(i), "next": g.next_experiments(i, 20),
96
+ "report": g.report(i)}
97
+ views["verify"] = g.verify()
98
+ return views
99
+
100
+
101
+ def verify_bundle(b: dict) -> dict:
102
+ """Check a bundle string-for-string: the chain, then every row against the event that wrote it."""
103
+ if not isinstance(b, dict) or b.get("format") != BUNDLE_FORMAT:
104
+ return {"ok": False, "problem": f"not a {BUNDLE_FORMAT} bundle"}
105
+ prev, checked, red = GENESIS, 0, 0
106
+ creates, revs, reviews, links, withdraws, retracts = {}, {}, {}, {}, set(), set()
107
+ for ev in sorted(b.get("events") or [], key=lambda e: e["seq"]):
108
+ if ev.get("redacted"):
109
+ if ev.get("prev") != prev:
110
+ return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (redacted event)"}
111
+ prev, red = ev["hash"], red + 1
112
+ continue
113
+ if _sha(ev["body"]) != ev["hash"]:
114
+ return {"ok": False, "broken_at": ev["seq"], "problem": "event body does not match its hash"}
115
+ body = json.loads(ev["body"])
116
+ if body.get("prev") != prev:
117
+ return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (prev does not match)"}
118
+ prev, checked = ev["hash"], checked + 1
119
+ k, ref, p = body["kind"], body["ref"], body["payload"]
120
+ if k == "create":
121
+ creates[ref] = p
122
+ revs[(ref, int(p.get("v", 1)))] = p.get("body_sha")
123
+ elif k == "revise":
124
+ revs[(ref, int(p.get("v")))] = p.get("body_sha")
125
+ elif k == "review":
126
+ reviews[p["review"]] = p
127
+ revs[(p["review"], 1)] = p.get("body_sha")
128
+ elif k == "link":
129
+ links[int(p["edge"])] = p
130
+ elif k == "withdraw":
131
+ withdraws.add(ref)
132
+ elif k == "retract":
133
+ retracts.add(int(p["edge"]))
134
+ if b.get("head") and b["head"] != prev:
135
+ return {"ok": False, "problem": "the chain does not end at the bundle's head"}
136
+ for n in b.get("nodes") or []:
137
+ c = creates.get(n["id"])
138
+ if n["type"] == "review":
139
+ if n["id"] not in reviews:
140
+ return {"ok": False, "problem": f"review {n['id']} has no event"}
141
+ elif not c or (c.get("type"), c.get("code"), c.get("inv")) != (n["type"], n["code"], n["inv"]):
142
+ return {"ok": False, "problem": f"node {n['id']} differs from its create event"}
143
+ for v in b.get("versions") or []:
144
+ want = revs.get((v["node"], v["v"]))
145
+ if want is None:
146
+ return {"ok": False, "problem": f"version {v['node']} v{v['v']} has no event"}
147
+ if _sha(v["body"]) != want:
148
+ return {"ok": False, "problem": f"version {v['node']} v{v['v']} differs from its event"}
149
+ for e in b.get("edges") or []:
150
+ want = links.get(e["id"])
151
+ if want is None:
152
+ return {"ok": False, "problem": f"edge {e['id']} has no event"}
153
+ if (want.get("rel"), want.get("dst"), want.get("dst_v")) != (e["rel"], e["dst"], e["dst_v"]) or \
154
+ _sha(e["data"]) != want.get("data_sha"):
155
+ return {"ok": False, "problem": f"edge {e['id']} differs from its event"}
156
+ for w in b.get("withdrawals") or []:
157
+ if w["node"] not in withdraws:
158
+ return {"ok": False, "problem": f"withdrawal of {w['node']} has no event"}
159
+ for r in b.get("retractions") or []:
160
+ if int(r["edge"]) not in retracts:
161
+ return {"ok": False, "problem": f"retraction of edge {r['edge']} has no event"}
162
+ out = {"ok": True, "checked": checked, "head": prev, "complete": red == 0, "redacted": red,
163
+ "nodes": len(b.get("nodes") or []), "edges": len(b.get("edges") or [])}
164
+ if red:
165
+ out["warning"] = (f"{red} event(s) outside this investigation are redacted: their content can't be checked, so "
166
+ "a change hidden among them (a withdrawal, say) would not show. The full record rules that "
167
+ "out.")
168
+ return out
169
+
170
+
171
+ def load(g: "Graph", b: dict) -> None:
172
+ """Replay a complete bundle into an empty graph, row for row — the result verifies exactly as the original."""
173
+ v = verify_bundle(b)
174
+ if not v["ok"]:
175
+ raise ValueError(f"bundle does not verify: {v['problem']}")
176
+ if not v["complete"]:
177
+ raise ValueError("a scoped bundle has redacted events and can't be replayed; view it, or ask for the full one")
178
+ with g._lock:
179
+ con = g._connect()
180
+ if con.execute("SELECT COUNT(*) FROM events").fetchone()[0]:
181
+ raise ValueError("load a bundle into an empty graph")
182
+ con.execute("BEGIN IMMEDIATE")
183
+ try:
184
+ for n in b["nodes"]:
185
+ con.execute("INSERT INTO nodes (id, type, code, inv, created_at, created_by) VALUES (?,?,?,?,?,?)",
186
+ (n["id"], n["type"], n["code"], n["inv"], n["created_at"], n["created_by"]))
187
+ for x in b["versions"]:
188
+ con.execute("INSERT INTO versions (node, v, at, by, reason, body) VALUES (?,?,?,?,?,?)",
189
+ (x["node"], x["v"], x["at"], x["by"], x["reason"], x["body"]))
190
+ for e in b["edges"]:
191
+ con.execute("INSERT INTO edges (id, src, src_v, rel, dst, dst_v, data, at, by, why) "
192
+ "VALUES (?,?,?,?,?,?,?,?,?,?)", (e["id"], e["src"], e["src_v"], e["rel"], e["dst"],
193
+ e["dst_v"], e["data"], e["at"], e["by"], e["why"]))
194
+ for w in b["withdrawals"]:
195
+ con.execute("INSERT INTO withdrawals (node, at, by, why) VALUES (?,?,?,?)",
196
+ (w["node"], w["at"], w["by"], w["why"]))
197
+ for r in b["retractions"]:
198
+ con.execute("INSERT INTO retractions (edge, at, by, why) VALUES (?,?,?,?)",
199
+ (r["edge"], r["at"], r["by"], r["why"]))
200
+ for x in b["leases"]:
201
+ con.execute("INSERT INTO leases (id, node, agent, task, kind, at, expires, note) "
202
+ "VALUES (?,?,?,?,?,?,?,?)", (x["id"], x["node"], x["agent"], x["task"], x["kind"],
203
+ x["at"], x["expires"], x["note"]))
204
+ for ev in b["events"]:
205
+ body = json.loads(ev["body"])
206
+ con.execute("INSERT INTO events (seq, at, actor, kind, ref, payload, prev, hash) "
207
+ "VALUES (?,?,?,?,?,?,?,?)", (ev["seq"], body["at"], body["actor"], body["kind"],
208
+ body["ref"], json.dumps(body["payload"], sort_keys=True,
209
+ ensure_ascii=False,
210
+ separators=(",", ":")),
211
+ body["prev"], ev["hash"]))
212
+ con.execute("COMMIT")
213
+ except BaseException:
214
+ con.execute("ROLLBACK")
215
+ raise
216
+ g._invalidate()
hypgraph/checks.py ADDED
@@ -0,0 +1,140 @@
1
+ """
2
+ Mechanical checks on a claim before it enters the record. Each rule exists because the mistake behind it was made:
3
+
4
+ - **every number accounted for** — a number in the statement must match a measurement bound to a result field, the
5
+ scope, or one of the investigation's declared constants ("all 49 in 6 of 6" when the result said 5 of 6);
6
+ - **absolutes need k of n** — "never", "all", "proves" need a measurement whose k is 0 or n;
7
+ - **exclusions need power** — "X is ruled out" needs a search shown to recover a planted answer, else it's
8
+ inconclusive;
9
+ - **conventions don't hedge** — agreed notation isn't "could be";
10
+ - **scope and evidence** — a finding says under what conditions it holds, and links what supports it.
11
+
12
+ Spelled-out two … twenty count as numbers ("two practice ciphers" when the record had one). Digits in quotes are
13
+ names ('63 63 63', "04"), not quantities.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Optional
20
+
21
+ from .derive import Snap, producing_run
22
+
23
+ _NUM = re.compile(r"(?<![\w.])(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?(\s*%)?(?![\w])")
24
+ _ABSOLUTE = re.compile(r"\b(never|always|all|every|none|impossible|cannot|can't|proves?|proven|certainly|"
25
+ r"guaranteed?)\b", re.I)
26
+ _HEDGE = re.compile(r"\b(could|might|may|likely|probably|perhaps|possibly|suggests?|consistent with|"
27
+ r"aligns? with|plausibl\w*)\b", re.I)
28
+ _WORDS = {w: i for i, w in enumerate("two three four five six seven eight nine ten eleven twelve thirteen fourteen "
29
+ "fifteen sixteen seventeen eighteen nineteen twenty".split(), start=2)}
30
+ _WORDNUM = re.compile(r"\b(" + "|".join(_WORDS) + r")\b", re.I) # 'one' is left out: too common as a pronoun
31
+ _LABEL = re.compile(r"""(['"`‘“])[0-9][0-9 ]{0,30}(['"`’”])""")
32
+
33
+
34
+ def numbers(text: str) -> list[tuple[float, int, bool, str]]:
35
+ """(value, decimals printed, is_percent, as printed) for each number in a text."""
36
+ out = []
37
+ for m in _NUM.finditer(text or ""):
38
+ whole, frac, pct = m.group(1), m.group(2), m.group(3)
39
+ v = float(whole.replace(",", "") + ("." + frac if frac else ""))
40
+ out.append((v, len(frac or ""), bool(pct), m.group(0).strip()))
41
+ for m in _WORDNUM.finditer(text or ""):
42
+ out.append((float(_WORDS[m.group(1).lower()]), 0, False, m.group(0)))
43
+ return out
44
+
45
+
46
+ def _matches(printed: tuple, candidates: list[float]) -> bool:
47
+ v, dec, pct, _ = printed
48
+ for c in candidates:
49
+ for x in ((c, c * 100) if pct or c <= 1 else (c,)):
50
+ if round(x, dec) == v or (dec == 0 and abs(x - v) < 0.5 and round(x) == v):
51
+ return True
52
+ return False
53
+
54
+
55
+ def _measure_values(m: dict) -> list[float]:
56
+ vals = []
57
+ for k in ("value", "k", "n", "lo", "hi"):
58
+ x = m.get(k)
59
+ if isinstance(x, (int, float)) and not isinstance(x, bool):
60
+ vals.append(float(x))
61
+ for x in m.get("interval") or []:
62
+ if isinstance(x, (int, float)):
63
+ vals.append(float(x))
64
+ return vals
65
+
66
+
67
+ def result_value(s: Snap, rid: str, key: str) -> Optional[dict]:
68
+ r = s.nodes.get(rid)
69
+ if not r or r["type"] != "result":
70
+ return None
71
+ v = (r["body"].get("values") or {}).get(key)
72
+ if isinstance(v, (int, float)):
73
+ return {"value": v}
74
+ return v if isinstance(v, dict) else None
75
+
76
+
77
+ def claim_problems(s: Snap, body: dict, support: list[str], inv: str) -> list[str]:
78
+ """What's wrong with a claim, as instructions to fix it (empty = it may enter the record). ``support``: the ids
79
+ of nodes that support it or that it is derived from."""
80
+ problems = []
81
+ kind = body.get("kind") or "finding"
82
+ stmt = body.get("statement", "")
83
+ scope = body.get("scope", "")
84
+ invb = s.nodes.get(inv, {}).get("body", {}) if inv else {}
85
+ if invb.get("strict_claims") is False:
86
+ return []
87
+ if kind in ("finding", "exclusion") and len(scope.strip()) < 10:
88
+ problems.append("scope: say under which conditions this holds (the data, method, sample) — at least a sentence")
89
+ if kind == "convention" and _HEDGE.search(stmt):
90
+ problems.append("a convention states agreed notation; a hedged interpretation (could / suggests / consistent "
91
+ "with) is a finding or a lead")
92
+ support = [x for x in support if x in s.nodes]
93
+ if kind in ("finding", "exclusion", "inconclusive") and not support:
94
+ problems.append("link the evidence: at least one result, claim or source that supports it "
95
+ "(links: {\"rel\": \"supports\", \"from\": <result id>, \"why\": …})")
96
+ meas = body.get("measurements") or []
97
+ coverage: list[float] = []
98
+ for i, m in enumerate(meas):
99
+ if not isinstance(m, dict) or not m.get("result") or not m.get("key"):
100
+ problems.append(f"measurement {i + 1}: give {{name, value (or k/n), result: <result id>, key: <field>}}")
101
+ continue
102
+ rv = result_value(s, str(m["result"]), str(m["key"]))
103
+ if rv is None:
104
+ problems.append(f"measurement {m.get('name') or i + 1}: result {m['result']} has no value '{m['key']}'")
105
+ continue
106
+ for k in ("value", "k", "n"):
107
+ if k in m and k in rv and isinstance(m[k], (int, float)) and isinstance(rv[k], (int, float)):
108
+ dec = len(str(m[k]).split(".")[1]) if "." in str(m[k]) else 0
109
+ if round(float(rv[k]), dec) != round(float(m[k]), dec) and not (
110
+ rv[k] <= 1 < m[k] and round(float(rv[k]) * 100, dec) == round(float(m[k]), dec)):
111
+ problems.append(f"measurement {m.get('name') or i + 1}: says {k}={m[k]} but result {m['result']} "
112
+ f"has {rv[k]}")
113
+ coverage += _measure_values(m) + _measure_values(rv)
114
+ allowed = coverage + [v for v, *_ in numbers(scope)] + [float(c) for c in (invb.get("constants") or [])
115
+ if isinstance(c, (int, float))]
116
+ loose = [p for p in numbers(_LABEL.sub(" ", stmt)) if not _matches(p, allowed)]
117
+ if loose:
118
+ problems.append("numbers in the statement that no bound measurement, the scope or the investigation's "
119
+ f"constants account for: {', '.join(p[3] for p in loose[:8])} — bind them to result fields "
120
+ "(measurements) or move design constants into the scope")
121
+ for w in {m.group(0).lower() for m in _ABSOLUTE.finditer(stmt)}:
122
+ exact = any(isinstance(m, dict) and m.get("n") and m.get("k") in (0, m.get("n")) for m in meas)
123
+ if not exact:
124
+ problems.append(f"'{w}' is absolute: back it with a k/n measurement where k is 0 or n, and keep the "
125
+ "scope to the trials actually run — or say 'rarely' / 'in k of n'")
126
+ if kind == "exclusion":
127
+ powered = False
128
+ for sid in support:
129
+ src = s.nodes[sid]
130
+ if src["type"] == "result" and src["body"].get("power") == "demonstrated":
131
+ powered = True
132
+ run = producing_run(s, src["id"]) if src["type"] == "result" else None
133
+ for e in s.out.get(run or "", []):
134
+ if e["rel"] == "executes" and s.nodes[e["dst"]]["body"].get("power_check"):
135
+ powered = True
136
+ if not powered:
137
+ problems.append("an exclusion needs a search that recovered a planted answer (a result with "
138
+ "power=demonstrated, or a run of an experiment with a power_check) — otherwise file it "
139
+ "as inconclusive")
140
+ return problems