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/derive.py ADDED
@@ -0,0 +1,390 @@
1
+ """
2
+ Standing, derived on every read. Nothing here is stored or settable:
3
+
4
+ lifecycle active | superseded | withdrawn (withdrawals, supersede / correct edges)
5
+ review unchecked | checked | contested | needs_revalidation (reviews pinned to versions; reviewers only)
6
+ execution for runs: queued | running | succeeded | failed | … (as recorded)
7
+ outcome for results: positive | negative | inconclusive | expected_failure | skipped | failed
8
+ evidence for claims and hypotheses, the strongest live support: external > new_data > separate_implementation
9
+ > project_rerun > lab_recorded > agent_reported > unverified
10
+ lines independent lines of support (lines sharing a run, a data artifact or a source count once)
11
+ at_risk the fallen nodes it rests on; ``unsupported`` when every support it had has fallen
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import defaultdict
17
+ from typing import Any, Optional
18
+
19
+ from .policy import Policy
20
+ from .schema import NEGATIVE, POSITIVE, PROFILE_LABEL, PROFILE_RANK, RISKY, SAME_TYPE
21
+
22
+
23
+ class Snap:
24
+ """The whole graph at one event, in memory. Built by Graph.snapshot(); treat it as read-only."""
25
+
26
+ def __init__(self, seq: int, policy: Policy):
27
+ self.seq = seq
28
+ self.policy = policy
29
+ self.nodes: dict[str, dict] = {}
30
+ self.edges: dict[int, dict] = {}
31
+ self.out: dict[str, list] = defaultdict(list)
32
+ self.inn: dict[str, list] = defaultdict(list)
33
+ self.withdrawn: dict[str, dict] = {}
34
+ self.leases: dict[str, dict] = {}
35
+ self._state: dict[str, dict] = {}
36
+ self.sig: Any = None
37
+ self.versions: dict[str, list] = defaultdict(list)
38
+ self.events: list[dict] = []
39
+ # highest rowid / id loaded per table: every table is append-only, so a later read loads only what's newer
40
+ self.marks: dict[str, int] = {"nodes": 0, "versions": 0, "edges": 0, "withdrawals": 0, "leases": 0}
41
+
42
+
43
+ def board(s: Snap, nid: str) -> set:
44
+ """Whose verdicts count on this node: the policy's reviewers, the investigation's own, and the curators."""
45
+ inv = s.nodes.get(s.nodes[nid]["inv"]) if nid in s.nodes else None
46
+ extra = set((inv or {}).get("body", {}).get("reviewers") or [])
47
+ return set(s.policy.reviewers) | extra | set(s.policy.curators)
48
+
49
+
50
+ def lifecycle(s: Snap, nid: str, _seen: frozenset = frozenset()) -> str:
51
+ if nid in s.withdrawn:
52
+ return "withdrawn"
53
+ for e in s.inn.get(nid, []):
54
+ # superseded by a live replacement (a cycle of supersedes edges can't recurse forever)
55
+ if e["rel"] in SAME_TYPE and e["src"] not in _seen and lifecycle(s, e["src"], _seen | {nid}) == "active":
56
+ return "superseded"
57
+ n = s.nodes[nid]
58
+ if n["type"] == "investigation":
59
+ return n["body"].get("state") or "active"
60
+ return "active"
61
+
62
+
63
+ def review_state(s: Snap, nid: str) -> dict:
64
+ """Reviews by board members, pinned to versions. An objection stays open until its reviewer (or a curator, or one
65
+ of the investigation's own reviewers) records ``resolved``; a negative verdict on the current version contests."""
66
+ n = s.nodes[nid]
67
+ brd = board(s, nid)
68
+ inv = s.nodes.get(n["inv"]) or {}
69
+ leads = set((inv.get("body") or {}).get("reviewers") or []) | set(s.policy.curators)
70
+ rows = []
71
+ for e in s.inn.get(nid, []):
72
+ if e["rel"] != "reviews":
73
+ continue
74
+ r = s.nodes.get(e["src"])
75
+ if not r or r["id"] in s.withdrawn:
76
+ continue
77
+ rows.append({"review": r["id"], "by": r["created_by"], "verdict": r["body"].get("verdict"),
78
+ "on_v": e["dst_v"] or 0, "at": r["created_at"],
79
+ "official": bool(r["body"].get("official", r["created_by"] in brd))})
80
+ rows.sort(key=lambda x: x["at"])
81
+ official = [x for x in rows if x["official"] and x["verdict"] != "comment"]
82
+ open_obj: dict[str, dict] = {}
83
+ for x in official:
84
+ if x["verdict"] == "objection":
85
+ open_obj[x["by"]] = x
86
+ elif x["verdict"] == "resolved":
87
+ if x["by"] in leads: # curators and the investigation's lead reviewers settle any dispute;
88
+ open_obj.clear() # everyone else, their own
89
+ else:
90
+ open_obj.pop(x["by"], None)
91
+ cur = [x for x in official if x["on_v"] == n["v"]]
92
+ latest: dict[str, dict] = {}
93
+ for x in cur:
94
+ if x["verdict"] not in ("objection", "resolved"):
95
+ latest[x["by"]] = x
96
+ positive = sorted({f"{x['by']}:{x['verdict']}" for x in latest.values() if x["verdict"] in POSITIVE})
97
+ negative = sorted({f"{x['by']}:{x['verdict']}" for x in latest.values() if x["verdict"] in NEGATIVE})
98
+ if open_obj or negative:
99
+ st = "contested"
100
+ elif positive:
101
+ st = "checked"
102
+ elif any(x["verdict"] in POSITIVE for x in official if x["on_v"] < n["v"]):
103
+ st = "needs_revalidation"
104
+ else:
105
+ st = "unchecked"
106
+ return {"state": st, "positive": positive, "negative": negative,
107
+ "open_objections": [{"by": x["by"], "review": x["review"]} for x in open_obj.values()],
108
+ "reviews": len(rows), "comments": sum(1 for x in rows if not x["official"] or x["verdict"] == "comment")}
109
+
110
+
111
+ def fallen(s: Snap, nid: str) -> bool:
112
+ """Broken as a premise: withdrawn, contested, or a failed run / result. A superseded node is NOT fallen — it was
113
+ refined, not refuted — so what rests on it is flagged softly (rests_on_superseded), not put AT RISK."""
114
+ n = s.nodes[nid]
115
+ if lifecycle(s, nid) == "withdrawn":
116
+ return True
117
+ if n["type"] in ("claim", "result", "hypothesis") and review_state(s, nid)["state"] == "contested":
118
+ return True
119
+ if n["type"] == "run" and n["body"].get("state") in ("failed", "timeout", "cancelled", "interrupted"):
120
+ return True
121
+ if n["type"] == "result" and n["body"].get("outcome") == "failed":
122
+ return True
123
+ return False
124
+
125
+
126
+ def upstream(s: Snap, x: str) -> list[tuple[str, str]]:
127
+ """What x rests on, one step: premises (requires), derivations, a decision's basis, and the run that produced it.
128
+ Support is counted separately (unsupported): one fallen support among several doesn't break a claim."""
129
+ ups = [(e["dst"], e["rel"]) for e in s.out.get(x, []) if e["rel"] in RISKY]
130
+ ups += [(e["src"], "produced_by") for e in s.inn.get(x, []) if e["rel"] == "produced"]
131
+ return ups
132
+
133
+
134
+ def downstream(s: Snap, x: str) -> list[tuple[str, str]]:
135
+ """What rests on x, one step: the inverse of upstream, plus what x supports."""
136
+ downs = [(e["src"], e["rel"]) for e in s.inn.get(x, []) if e["rel"] in RISKY]
137
+ downs += [(e["dst"], "produced") for e in s.out.get(x, []) if e["rel"] == "produced"]
138
+ downs += [(e["dst"], "supports") for e in s.out.get(x, []) if e["rel"] == "supports"]
139
+ return downs
140
+
141
+
142
+ def walk(s: Snap, nid: str, step, limit: int) -> list[tuple[str, str, int]]:
143
+ seen, out, frontier = {nid}, [], [(nid, 0)]
144
+ while frontier and len(out) < limit:
145
+ nxt = []
146
+ for x, d in frontier:
147
+ for y, rel in step(s, x):
148
+ if y not in seen:
149
+ seen.add(y)
150
+ out.append((y, rel, d + 1))
151
+ nxt.append((y, d + 1))
152
+ frontier = nxt
153
+ return out
154
+
155
+
156
+ def ancestors(s: Snap, nid: str, limit: int = 500) -> list[tuple[str, str, int]]:
157
+ return walk(s, nid, upstream, limit)
158
+
159
+
160
+ def dependents(s: Snap, nid: str, limit: int = 500) -> list[tuple[str, str, int]]:
161
+ return walk(s, nid, downstream, limit)
162
+
163
+
164
+ def supporters(s: Snap, nid: str) -> list[dict]:
165
+ return [e for e in s.inn.get(nid, []) if e["rel"] == "supports"]
166
+
167
+
168
+ def at_risk(s: Snap, nid: str) -> list[str]:
169
+ return list(dict.fromkeys(a for a, _r, _d in ancestors(s, nid) if fallen(s, a)))
170
+
171
+
172
+ def rests_on_superseded(s: Snap, nid: str) -> list[str]:
173
+ return [a for a, _r, _d in ancestors(s, nid) if lifecycle(s, a) == "superseded"]
174
+
175
+
176
+ def out_of_play(s: Snap, nid: str) -> bool:
177
+ return fallen(s, nid) or lifecycle(s, nid) == "superseded"
178
+
179
+
180
+ def unsupported(s: Snap, nid: str) -> bool:
181
+ sup = supporters(s, nid)
182
+ return bool(sup) and all(fallen(s, e["src"]) for e in sup)
183
+
184
+
185
+ def producing_run(s: Snap, rid: str) -> Optional[str]:
186
+ for e in s.inn.get(rid, []):
187
+ if e["rel"] == "produced":
188
+ return e["src"]
189
+ for e in s.out.get(rid, []):
190
+ if e["rel"] == "derived_from" and s.nodes[e["dst"]]["type"] == "run":
191
+ return e["dst"]
192
+ return None
193
+
194
+
195
+ def line_profile(s: Snap, src: str, depth: int = 0) -> str:
196
+ n = s.nodes[src]
197
+ if n["type"] == "source":
198
+ return "external"
199
+ if n["type"] == "claim":
200
+ return profile(s, src, depth + 1) if depth < 4 else "unverified"
201
+ run = producing_run(s, src)
202
+ if not run:
203
+ # "cited" counts as outside evidence only when it cites a source node; the word alone is a report
204
+ cites = any(e["rel"] == "cites" and s.nodes[e["dst"]]["type"] == "source" and not fallen(s, e["dst"])
205
+ for e in s.out.get(src, []))
206
+ return "external" if n["body"].get("kind") == "cited" and cites else "unverified"
207
+ rb = s.nodes[run]["body"]
208
+ # Lab-recorded means the instrument filed this result itself (only it adds `produced` from a lab run). A result
209
+ # someone derived from a lab run, or a run they call "external", is their report; outside evidence is a source.
210
+ by_lab = rb.get("kind") == "lab" and any(e["rel"] == "produced" and e["src"] == run for e in s.inn.get(src, []))
211
+ best = "lab_recorded" if by_lab else "agent_reported"
212
+ for e in s.out.get(run, []) + s.inn.get(run, []):
213
+ if e["rel"] == "replicates" and not fallen(s, e["src"]):
214
+ k = {"same_code": "project_rerun", "new_seed": "project_rerun", "new_env": "project_rerun",
215
+ "new_impl": "separate_implementation", "new_data": "new_data"}.get(e["data"].get("differs"),
216
+ "project_rerun")
217
+ if PROFILE_RANK.index(k) > PROFILE_RANK.index(best):
218
+ best = k
219
+ return best
220
+
221
+
222
+ def profile(s: Snap, nid: str, depth: int = 0) -> str:
223
+ lines = [e["src"] for e in supporters(s, nid) if not fallen(s, e["src"])]
224
+ if not lines:
225
+ return "unverified"
226
+ return max((line_profile(s, x, depth) for x in lines), key=PROFILE_RANK.index)
227
+
228
+
229
+ def roots(s: Snap, src: str) -> set[str]:
230
+ """What a line of evidence is made of: its run(s) and the data / model artifacts they used, its sources."""
231
+ out: set[str] = set()
232
+ stack, seen = [src], set()
233
+ while stack and len(seen) < 200:
234
+ x = stack.pop()
235
+ if x in seen:
236
+ continue
237
+ seen.add(x)
238
+ n = s.nodes[x]
239
+ if n["type"] == "run":
240
+ out.add(x)
241
+ for e in s.out.get(x, []):
242
+ if e["rel"] == "uses" and (e["data"].get("role") or "data") in ("data", "model"):
243
+ out.add(e["dst"])
244
+ continue
245
+ if n["type"] in ("artifact", "source"):
246
+ out.add(x)
247
+ continue
248
+ run = producing_run(s, x)
249
+ if run:
250
+ stack.append(run)
251
+ for e in s.out.get(x, []):
252
+ if e["rel"] in ("derived_from",):
253
+ stack.append(e["dst"])
254
+ if n["type"] == "claim":
255
+ for e in supporters(s, x):
256
+ stack.append(e["src"])
257
+ # No recorded run, data or source behind it: we can't tell these lines apart, so they all count as one
258
+ # (conservative — three unprovenanced reports may well be one result repeated).
259
+ return out or {"(no provenance)"}
260
+
261
+
262
+ def independent_lines(s: Snap, nid: str) -> int:
263
+ """Support lines that share no run, data artifact or source count separately; any overlap merges them."""
264
+ lines = [e["src"] for e in supporters(s, nid) if not fallen(s, e["src"])]
265
+ groups: list[set] = []
266
+ for x in lines:
267
+ r = roots(s, x)
268
+ merged = [g for g in groups if g & r]
269
+ for g in merged:
270
+ groups.remove(g)
271
+ r |= g
272
+ groups.append(r)
273
+ return len(groups)
274
+
275
+
276
+ def basis_changed(s: Snap, nid: str) -> list[dict]:
277
+ """A decision's basis that is no longer what the decision saw: a node revised since the decision cited it."""
278
+ out = []
279
+ for e in s.out.get(nid, []):
280
+ if e["rel"] != "based_on":
281
+ continue
282
+ cur = s.nodes[e["dst"]]["v"]
283
+ if e["dst_v"] and cur > e["dst_v"]:
284
+ out.append({"node": e["dst"], "code": s.nodes[e["dst"]]["code"], "was_v": e["dst_v"], "now_v": cur})
285
+ return out
286
+
287
+
288
+ # What a node's standing reads. Out-edges: what it rests on or is made of. In-edges: what reviews, supports,
289
+ # challenges, supersedes or produced it. A change at y can alter the standing of x only if x reads y this way —
290
+ # directly, or through a chain (at-risk and evidence are transitive).
291
+ _READS_OUT = {"requires", "derived_from", "based_on", "cites", "uses", "replicates"}
292
+ _READS_IN = {"reviews", "supports", "challenges", "supersedes", "corrects", "produced", "replicates"}
293
+
294
+
295
+ def affected_by(s: Snap, touched: set) -> set:
296
+ """Every node whose derived standing can have changed when ``touched`` changed: the touched nodes and,
297
+ transitively, everything that reads them. A touched investigation (its reviewers, strictness, state) affects
298
+ every node in it. Used to keep cached standing across a write instead of re-deriving the whole graph."""
299
+ out: set = set()
300
+ stack = [x for x in touched if x in s.nodes]
301
+ invs = {x for x in stack if s.nodes[x]["type"] == "investigation"}
302
+ if invs:
303
+ stack += [i for i, n in s.nodes.items() if n["inv"] in invs]
304
+ while stack:
305
+ y = stack.pop()
306
+ if y in out:
307
+ continue
308
+ out.add(y)
309
+ stack += [e["src"] for e in s.inn.get(y, ()) if e["rel"] in _READS_OUT and e["src"] not in out]
310
+ stack += [e["dst"] for e in s.out.get(y, ()) if e["rel"] in _READS_IN and e["dst"] not in out]
311
+ return out
312
+
313
+
314
+ def state(s: Snap, nid: str) -> dict:
315
+ """Every standing dimension of a node, plus one honest sentence. Cached on the snapshot."""
316
+ if nid in s._state:
317
+ return s._state[nid]
318
+ n = s.nodes[nid]
319
+ t = n["type"]
320
+ st: dict[str, Any] = {"lifecycle": lifecycle(s, nid)}
321
+ if t in ("claim", "result", "run", "experiment", "hypothesis"):
322
+ st["review"] = review_state(s, nid)
323
+ if t == "run":
324
+ st["execution"] = n["body"].get("state") or "unknown"
325
+ if t == "result":
326
+ st["outcome"] = n["body"].get("outcome") or "unstated"
327
+ if t in ("claim", "hypothesis"):
328
+ st["evidence"] = profile(s, nid)
329
+ st["independent_lines"] = independent_lines(s, nid)
330
+ st["challenges"] = sum(1 for e in s.inn.get(nid, []) if e["rel"] == "challenges" and not fallen(s, e["src"]))
331
+ st["unsupported"] = unsupported(s, nid)
332
+ if t == "decision":
333
+ moved = basis_changed(s, nid)
334
+ if moved:
335
+ st["basis_changed"] = moved
336
+ risk = at_risk(s, nid)
337
+ if risk:
338
+ st["at_risk"] = risk
339
+ older = rests_on_superseded(s, nid)
340
+ if older:
341
+ st["rests_on_superseded"] = older
342
+ if nid in s.leases:
343
+ L = s.leases[nid]
344
+ st["lease"] = {"agent": L["agent"], "task": L["task"], "expires": L["expires"]}
345
+ st["fallen"] = fallen(s, nid)
346
+ st["sentence"] = sentence(s, nid, st)
347
+ s._state[nid] = st
348
+ return st
349
+
350
+
351
+ def sentence(s: Snap, nid: str, st: dict) -> str:
352
+ """The node's standing in one honest line."""
353
+ n = s.nodes[nid]
354
+ parts = [f"{st['lifecycle']} {n['type']}"]
355
+ rv = st.get("review")
356
+ if rv:
357
+ if rv["state"] == "checked":
358
+ parts.append("checked (" + ", ".join(rv["positive"]) + f", v{n['v']})")
359
+ elif rv["state"] == "contested":
360
+ who = [o["by"] for o in rv["open_objections"]] + rv["negative"]
361
+ parts.append("CONTESTED (" + ", ".join(who) + ")")
362
+ elif rv["state"] == "needs_revalidation":
363
+ parts.append(f"review stale — v{n['v']} not yet re-checked")
364
+ else:
365
+ parts.append("unchecked")
366
+ if "execution" in st:
367
+ parts.append(st["execution"])
368
+ if "outcome" in st:
369
+ parts.append(f"outcome {st['outcome']}")
370
+ if "evidence" in st:
371
+ k = st["independent_lines"]
372
+ parts.append(f"{k} independent line{'s' if k != 1 else ''} of support" if k else "no live support")
373
+ parts.append(PROFILE_LABEL[st["evidence"]])
374
+ if st["evidence"] != "external":
375
+ parts.append("no external replication")
376
+ if st.get("challenges"):
377
+ parts.append(f"{st['challenges']} challenge(s)")
378
+ if st.get("unsupported"):
379
+ parts.append("UNSUPPORTED — every support has fallen")
380
+ if st.get("basis_changed"):
381
+ parts.append("REVISIT — its basis changed: " + ", ".join(
382
+ f"{b['code']} v{b['was_v']}→v{b['now_v']}" for b in st["basis_changed"][:5]))
383
+ if st.get("at_risk"):
384
+ parts.append("AT RISK — rests on " + ", ".join(s.nodes[a]["code"] or a for a in st["at_risk"][:5]))
385
+ if st.get("rests_on_superseded"):
386
+ parts.append("rests on superseded " + ", ".join(s.nodes[a]["code"] or a for a in st["rests_on_superseded"][:5])
387
+ + " — check it against the replacement")
388
+ if st.get("lease"):
389
+ parts.append(f"being worked by {st['lease']['agent']}")
390
+ return " · ".join(parts)