hypgraph 0.7.7__tar.gz → 0.7.8__tar.gz
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-0.7.7 → hypgraph-0.7.8}/CHANGELOG.md +13 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/PKG-INFO +1 -1
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/spec.md +1 -1
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/__init__.py +3 -2
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/graph.py +15 -0
- hypgraph-0.7.8/src/hypgraph/references.py +173 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/schema.py +4 -2
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/server.py +11 -0
- hypgraph-0.7.8/tests/test_references.py +156 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/.gitignore +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/AGENTS.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/CITATION.cff +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/LICENSE +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/NOTICE +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/README.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/PLAN.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/site-plan.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/pyproject.toml +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/cli.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_coord.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_d10.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_engine.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.8 — 2026-10-29: pinned references between records
|
|
4
|
+
|
|
5
|
+
- **A record can cite a node of another record and prove what it read.** A `source` takes four more fields,
|
|
6
|
+
`record` (the cited record's URL), `node`, `version` and `hash` (sha256 of that version's canonical body, the
|
|
7
|
+
same hash the cited record's chain holds), all four or none (`SHAPE_REFERENCE` otherwise). `Graph.pin(node,
|
|
8
|
+
record_url)` / `GET /api/graph/pin/{id}?record=` build them for one of this record's nodes, with the node's own
|
|
9
|
+
words in `citation` and `says`, so the citing record reads without the cited one. `Graph.references(inv)` /
|
|
10
|
+
`GET /api/graph/references` list the sources here that pin other records, with who cites them.
|
|
11
|
+
`hypgraph.verify_reference(fields, fetch=)` reads the cited record's node route (HTTP, or a host's own reader)
|
|
12
|
+
and reports whether the pinned version still hashes to the pin, whether it was revised since, and whether it
|
|
13
|
+
was withdrawn. The citing record never trusts the other's status; it trusts the hash it pinned. A record stays
|
|
14
|
+
one trust boundary; a host that holds many (a mesh) derives the network from these pins.
|
|
15
|
+
|
|
3
16
|
## 0.7.7 — 2026-10-29: `hypgraph serve` inside a container
|
|
4
17
|
|
|
5
18
|
- **`hypgraph serve` bound to `0.0.0.0` answered 421 to everyone.** Its Host check knew only the bind address,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.8
|
|
4
4
|
Summary: The evidence layer for AI research agents: a hash-chained research record that checks every claim as it is written and derives how well each is known.
|
|
5
5
|
Project-URL: Homepage, https://hypgraph.com
|
|
6
6
|
Project-URL: Documentation, https://hypgraph.com/engineers/
|
|
@@ -28,7 +28,7 @@ correction is a new row, never an edited one.
|
|
|
28
28
|
| `run` | RUN | **kind**\*, job_id, name, state, code, inputs, outputs, args, config, seed, env, started, finished, exit_code, log_sha256, note | One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument. |
|
|
29
29
|
| `result` | RS | **summary**\*, kind, outcome, values, expected, limitations, power, evidence_note, legacy | What a run (or an observation, or a citation) found: values, outcome, power. |
|
|
30
30
|
| `artifact` | A | **sha256**\*, path, size, format | Exact bytes, by sha256 — data, code, a model, a figure. |
|
|
31
|
-
| `source` | S | **citation**\*, doi, url, passage, retrieved, says | Outside literature or data: citation, DOI, the passage that says it. |
|
|
31
|
+
| `source` | S | **citation**\*, doi, url, passage, retrieved, says, kind, reliability, record, node, version, hash | Outside literature or data: citation, DOI, the passage that says it. Or another record's node, pinned: `record` (its URL), `node`, `version` and `hash` (sha256 of that version's canonical body) — all four or none. |
|
|
32
32
|
| `review` | RV | **verdict**\*, **method**\*, **notes**\*, official | A verdict on one version of a claim, result, run, experiment or hypothesis. |
|
|
33
33
|
| `decision` | D | **choice**\*, alternatives, **reasons**\*, revisit_if, meeting | A choice made on the evidence; flagged when what it was based on changes. |
|
|
34
34
|
| `task` | T | **kanban_id**\*, title | A unit of work in your tracker, linked to what it works on. |
|
|
@@ -16,13 +16,14 @@ Standing is derived on every read, never stored; every write is an event in a SH
|
|
|
16
16
|
their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
__version__ = "0.7.
|
|
19
|
+
__version__ = "0.7.8"
|
|
20
20
|
|
|
21
21
|
from .bundle import verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
23
23
|
from .policy import Policy # noqa: E402
|
|
24
|
+
from .references import verify_reference # noqa: E402
|
|
24
25
|
from .schema import FORMAT, describe # noqa: E402
|
|
25
26
|
from .store import verify_file # noqa: E402
|
|
26
27
|
|
|
27
|
-
__all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe",
|
|
28
|
+
__all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe", "verify_reference",
|
|
28
29
|
"FORMAT", "__version__"]
|
|
@@ -28,6 +28,7 @@ from typing import Any, Callable, Optional
|
|
|
28
28
|
|
|
29
29
|
from . import profiles
|
|
30
30
|
from . import derive as D
|
|
31
|
+
from . import references as R
|
|
31
32
|
from . import refusals, store
|
|
32
33
|
from .checks import claim_issues, value_issues
|
|
33
34
|
from .policy import Policy
|
|
@@ -158,6 +159,12 @@ def clean_body(ntype: str, body: dict, *, partial_ok: bool = False) -> dict:
|
|
|
158
159
|
if len(canon(v)) > n:
|
|
159
160
|
raise _shape("SHAPE_TOO_LONG", f"{k} is too long", words=[k])
|
|
160
161
|
out[k] = v
|
|
162
|
+
if ntype == "source":
|
|
163
|
+
try:
|
|
164
|
+
out = R.check_fields(out)
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
raise _shape("SHAPE_REFERENCE", str(e), "pin another record's node with record, node, version and hash "
|
|
167
|
+
"(its /api/graph/pin/<id>?record=<url> gives them)", ["record", "node", "version", "hash"])
|
|
161
168
|
return out
|
|
162
169
|
|
|
163
170
|
|
|
@@ -1225,6 +1232,14 @@ class Graph:
|
|
|
1225
1232
|
out["versions"] = list(s.versions.get(nid, []))
|
|
1226
1233
|
return out
|
|
1227
1234
|
|
|
1235
|
+
def pin(self, nid: str, record: str) -> dict:
|
|
1236
|
+
"""Source fields another record can cite this node by: its version and hash, pinned (references.pin)."""
|
|
1237
|
+
return R.pin(self, nid, record)
|
|
1238
|
+
|
|
1239
|
+
def references(self, inv: str = "") -> list[dict]:
|
|
1240
|
+
"""The sources here that pin nodes of other records, with who cites them (references.references)."""
|
|
1241
|
+
return R.references(self, inv)
|
|
1242
|
+
|
|
1228
1243
|
def resolve(self, ref: str, inv: str = "") -> str:
|
|
1229
1244
|
"""A node id from an id or a display code (CL3, H2, X4 …), within an investigation when given."""
|
|
1230
1245
|
ref = str(ref or "").strip()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Pinned references: one record citing a node of another record, so that records can form a network without
|
|
2
|
+
sharing a chain.
|
|
3
|
+
|
|
4
|
+
A record is one trust boundary: one hash chain, one policy, one board. Another record's claim can still be a line
|
|
5
|
+
of support here — as a ``source`` node, which is what a cited outside work already is — provided the reference
|
|
6
|
+
says *which version* it read and *what that version hashed to*. The fields on ``source``:
|
|
7
|
+
|
|
8
|
+
record the served record's base URL (what its /api/graph hangs off), e.g. https://mesh/q/demo-a
|
|
9
|
+
node the node id there
|
|
10
|
+
version the version that was read
|
|
11
|
+
hash sha256 of that version's canonical body — the same hash the record's own chain holds for it
|
|
12
|
+
|
|
13
|
+
``pin`` builds those fields for one of this record's nodes; ``verify_reference`` fetches the cited record and checks
|
|
14
|
+
them: the hash still matches (nothing was rewritten), whether the node has been revised since (the pinned version
|
|
15
|
+
stands as read, but there is a newer one), and whether it has been withdrawn. A host that holds many records (a
|
|
16
|
+
mesh) uses the same functions against its own copies and derives cross-record standing from them. This record never
|
|
17
|
+
trusts another record's status; it trusts the hash it pinned."""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
27
|
+
|
|
28
|
+
from . import store
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .graph import Graph
|
|
32
|
+
|
|
33
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
34
|
+
_URL = re.compile(r"^https?://[^\s/?#]+[^\s]*$", re.I)
|
|
35
|
+
Fetch = Callable[[str], dict]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def body_hash(body: dict) -> str:
|
|
39
|
+
"""The hash a record's chain holds for a version: sha256 over the canonical JSON of its body."""
|
|
40
|
+
return store.sha(store.canon(body))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_record(url: str) -> str:
|
|
44
|
+
"""A record URL without a trailing slash, fragment or query; refuses anything that is not http(s)."""
|
|
45
|
+
u = str(url or "").strip()
|
|
46
|
+
if not _URL.match(u):
|
|
47
|
+
raise ValueError(f"record must be the cited record's http(s) URL, not {u!r}")
|
|
48
|
+
p = urllib.parse.urlsplit(u)
|
|
49
|
+
return urllib.parse.urlunsplit((p.scheme.lower(), p.netloc, p.path.rstrip("/"), "", ""))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_fields(body: dict) -> dict:
|
|
53
|
+
"""Validate the reference part of a source body (after the generic field cleaning). All four fields or none;
|
|
54
|
+
version a positive integer; hash 64 hex characters; record an http(s) URL. Returns the body with the fields
|
|
55
|
+
normalised."""
|
|
56
|
+
keys = ("record", "node", "version", "hash")
|
|
57
|
+
present = [k for k in keys if body.get(k) not in (None, "", 0)]
|
|
58
|
+
if not present:
|
|
59
|
+
return body
|
|
60
|
+
missing = [k for k in keys if k not in present]
|
|
61
|
+
if missing:
|
|
62
|
+
raise ValueError("a reference to another record names all of record, node, version and hash; missing: "
|
|
63
|
+
+ ", ".join(missing))
|
|
64
|
+
out = dict(body)
|
|
65
|
+
out["record"] = normalize_record(body["record"])
|
|
66
|
+
out["node"] = str(body["node"]).strip()
|
|
67
|
+
v = body["version"]
|
|
68
|
+
try:
|
|
69
|
+
fv = float(v)
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
raise ValueError(f"version must be a positive whole number, not {v!r}")
|
|
72
|
+
if not fv.is_integer() or fv < 1:
|
|
73
|
+
raise ValueError(f"version must be a positive whole number, not {v!r}")
|
|
74
|
+
out["version"] = int(fv)
|
|
75
|
+
h = str(body["hash"]).strip().lower()
|
|
76
|
+
if not _HEX64.match(h):
|
|
77
|
+
raise ValueError("hash must be the 64-hex sha256 of the cited version's canonical body")
|
|
78
|
+
out["hash"] = h
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_reference(body: dict) -> bool:
|
|
83
|
+
return bool(body.get("record")) and bool(body.get("hash"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pin(g: "Graph", nid: str, record: str, *, now: str = "") -> dict:
|
|
87
|
+
"""Source fields that pin ``nid`` of ``g`` as it stands now, for another record to cite. ``record`` is the URL
|
|
88
|
+
this record is served at. The citation and ``says`` carry the node's own words, so the citing record reads
|
|
89
|
+
without the cited one."""
|
|
90
|
+
s = g.snapshot()
|
|
91
|
+
nid = g.resolve(nid)
|
|
92
|
+
n = s.nodes[nid]
|
|
93
|
+
rec = normalize_record(record)
|
|
94
|
+
brief = g.brief(s, nid)
|
|
95
|
+
title = brief["title"] or n["type"]
|
|
96
|
+
when = now or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
97
|
+
return {"citation": f"{brief['code']} ({n['type']}) in {rec}, version {n['v']}: {title}"[:1000],
|
|
98
|
+
"url": f"{rec}/#node={nid}", "kind": "primary", "retrieved": when,
|
|
99
|
+
"says": str(title)[:1500],
|
|
100
|
+
"record": rec, "node": nid, "version": int(n["v"]), "hash": body_hash(n["body"])}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def references(g: "Graph", inv: str = "") -> list[dict]:
|
|
104
|
+
"""Every source in ``g`` that pins a node of another record, with who cites it here. What a host walks to build
|
|
105
|
+
the network between records."""
|
|
106
|
+
s = g.snapshot()
|
|
107
|
+
out = []
|
|
108
|
+
for nid, n in s.nodes.items():
|
|
109
|
+
if n["type"] != "source" or not is_reference(n["body"]) or (inv and n["inv"] != inv):
|
|
110
|
+
continue
|
|
111
|
+
b = n["body"]
|
|
112
|
+
out.append({"id": nid, "code": n["code"], "inv": n["inv"], "record": b["record"], "node": b["node"],
|
|
113
|
+
"version": b["version"], "hash": b["hash"], "lifecycle": g.state(nid)["lifecycle"],
|
|
114
|
+
"cited_by": sorted({e["src"] for e in s.inn.get(nid, []) if e["rel"] == "cites"})})
|
|
115
|
+
out.sort(key=lambda r: (r["record"], r["node"], r["id"]))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _http_fetch(timeout: float) -> Fetch:
|
|
120
|
+
def fetch(url: str) -> dict:
|
|
121
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
122
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
123
|
+
return json.load(r)
|
|
124
|
+
return fetch
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def verify_reference(fields: dict, *, fetch: Optional[Fetch] = None, timeout: float = 10.0) -> dict:
|
|
128
|
+
"""Check a pinned reference against the record it points at.
|
|
129
|
+
|
|
130
|
+
``fetch(url) -> dict`` answers ``GET <record>/api/graph/node/<node>?history=1``; the default is HTTP. A host
|
|
131
|
+
that holds the cited record passes its own reader. Returns::
|
|
132
|
+
|
|
133
|
+
{ok, problem, record, node, version, current_version, revised, withdrawn, lifecycle}
|
|
134
|
+
|
|
135
|
+
``ok`` means the pinned version exists there and its body hashes to the pinned hash: what this record read is
|
|
136
|
+
what that record holds. ``revised`` (a newer version exists) and ``withdrawn`` are reported beside it — the
|
|
137
|
+
pin still stands as read, but the citing record's host decides what that means for its standing."""
|
|
138
|
+
try:
|
|
139
|
+
f = check_fields({k: fields.get(k) for k in ("record", "node", "version", "hash")})
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
return {"ok": False, "problem": str(e)}
|
|
142
|
+
if not is_reference(f):
|
|
143
|
+
return {"ok": False, "problem": "not a reference: no record and hash pinned"}
|
|
144
|
+
url = f"{f['record']}/api/graph/node/{urllib.parse.quote(f['node'], safe='')}?history=1"
|
|
145
|
+
out = {"ok": False, "record": f["record"], "node": f["node"], "version": f["version"], "url": url}
|
|
146
|
+
try:
|
|
147
|
+
doc = (fetch or _http_fetch(timeout))(url)
|
|
148
|
+
except urllib.error.HTTPError as e:
|
|
149
|
+
return {**out, "problem": f"the record answered {e.code} for {f['node']}"}
|
|
150
|
+
except Exception as e: # unreachable, bad JSON, timeout: the reference is unverified, not wrong
|
|
151
|
+
return {**out, "problem": f"could not read the record: {type(e).__name__}"}
|
|
152
|
+
if not isinstance(doc, dict) or "versions" not in doc:
|
|
153
|
+
return {**out, "problem": "the record's answer carries no versions (is it a HypGraph node route with history=1?)"}
|
|
154
|
+
versions = {int(v.get("v", 0)): v for v in doc.get("versions") or [] if isinstance(v, dict)}
|
|
155
|
+
cur = max(versions) if versions else int(doc.get("v") or 0)
|
|
156
|
+
out |= {"current_version": cur, "revised": cur > f["version"], "withdrawn": bool(doc.get("withdrawn")),
|
|
157
|
+
"lifecycle": str((doc.get("state") or {}).get("lifecycle") or ("withdrawn" if doc.get("withdrawn") else ""))}
|
|
158
|
+
ver = versions.get(f["version"])
|
|
159
|
+
if ver is None:
|
|
160
|
+
return {**out, "problem": f"version {f['version']} does not exist there (it has {cur})"}
|
|
161
|
+
body = ver.get("body")
|
|
162
|
+
if isinstance(body, str):
|
|
163
|
+
try:
|
|
164
|
+
body = json.loads(body)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
return {**out, "problem": "the version's body is not JSON"}
|
|
167
|
+
got = body_hash(body) if isinstance(body, dict) else ""
|
|
168
|
+
if got != f["hash"]:
|
|
169
|
+
return {**out, "problem": f"version {f['version']} hashes to {got[:16]}…, not the pinned {f['hash'][:16]}…"}
|
|
170
|
+
return {**out, "ok": True}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["pin", "references", "verify_reference", "check_fields", "is_reference", "body_hash", "normalize_record"]
|
|
@@ -65,7 +65,9 @@ TYPES: dict[str, dict] = {
|
|
|
65
65
|
"source": {"prefix": "S", "fields": {
|
|
66
66
|
"citation": _f("text", 1000, True), "doi": _f("str", 200), "url": _f("str", 500), "passage": _f("text", 3000),
|
|
67
67
|
"retrieved": _f("str", 40), "says": _f("text", 1500), "kind": _f("enum:primary|secondary|tertiary"),
|
|
68
|
-
"reliability": _f("text", 1000)
|
|
68
|
+
"reliability": _f("text", 1000),
|
|
69
|
+
# a pinned reference to a node of another record (references.py): all four or none
|
|
70
|
+
"record": _f("str", 500), "node": _f("str", 80), "version": _f("num"), "hash": _f("str", 64)}},
|
|
69
71
|
"review": {"prefix": "RV", "fields": {
|
|
70
72
|
"verdict": _f("enum:" + "|".join(sorted(VERDICTS)), req=True), "method": _f("text", 1500, True),
|
|
71
73
|
"notes": _f("text", 4000, True), "official": _f("bool")}},
|
|
@@ -179,7 +181,7 @@ ABOUT = {
|
|
|
179
181
|
"run": "One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument.",
|
|
180
182
|
"result": "What a run (or an observation, or a citation) found: values, outcome, power.",
|
|
181
183
|
"artifact": "Exact bytes, by sha256 — data, code, a model, a figure.",
|
|
182
|
-
"source": "Outside literature or data: citation, DOI, the passage that says it.",
|
|
184
|
+
"source": "Outside literature or data: citation, DOI, the passage that says it — or another record's node, pinned by version and hash.",
|
|
183
185
|
"review": "A verdict on one version of a claim, result, run, experiment or hypothesis.",
|
|
184
186
|
"decision": "A choice made on the evidence; flagged when what it was based on changes.",
|
|
185
187
|
"task": "A unit of work linked to what it works on — or a work order: objective, assignee, done-when "
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
GET /api/graph/investigations {investigations, stats}
|
|
6
6
|
GET /api/graph/subgraph?inv=&types= nodes (with standing) and live edges
|
|
7
7
|
GET /api/graph/node/{id}?history=1 one node, its links, its versions
|
|
8
|
+
GET /api/graph/pin/{id}?record=URL source fields another record cites this node by (version + hash pinned)
|
|
9
|
+
GET /api/graph/references?inv= the sources here that pin nodes of other records
|
|
8
10
|
GET /api/graph/why/{id} what it rests on /impact/{id} what rests on it
|
|
9
11
|
GET /api/graph/neighborhood/{id}?depth=
|
|
10
12
|
GET /api/graph/search?text=&type=&inv=&limit=
|
|
@@ -160,6 +162,15 @@ def dispatch(g: Graph, method: str, path: str, query: dict | None = None, body:
|
|
|
160
162
|
return 200, g.subgraph(inv, types=types)
|
|
161
163
|
if head == "node" and arg:
|
|
162
164
|
return 200, g.get(arg, history=q.get("history") in ("1", "true"))
|
|
165
|
+
if head == "pin" and arg:
|
|
166
|
+
if not q.get("record"):
|
|
167
|
+
return 400, {"detail": "record=<the URL this record is served at> is required"}
|
|
168
|
+
try:
|
|
169
|
+
return 200, g.pin(arg, q["record"])
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
return 400, {"detail": str(e)}
|
|
172
|
+
if head == "references":
|
|
173
|
+
return 200, {"references": g.references(inv)}
|
|
163
174
|
if head == "why" and arg:
|
|
164
175
|
return 200, g.why(arg)
|
|
165
176
|
if head == "impact" and arg:
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Pinned references between records: record B cites a node of record A as a source that names the version it read
|
|
2
|
+
and that version's hash. The pin is built by A (``pin``), checked against A (``verify_reference``), and listed by B
|
|
3
|
+
(``references``). A is read the way a host reads it: through the API's node route with history."""
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hypgraph import Graph, GraphError, Policy, verify_reference
|
|
11
|
+
from hypgraph import derive as D
|
|
12
|
+
from hypgraph.references import body_hash, check_fields, normalize_record
|
|
13
|
+
from hypgraph.server import dispatch, make_server
|
|
14
|
+
|
|
15
|
+
RECORD_A = "https://mesh.example.org/q/demo-a"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def a():
|
|
20
|
+
"""Record A: one checked claim with a bound measurement."""
|
|
21
|
+
g = Graph(policy=Policy(reviewers={"critic"}, instrument="lab"))
|
|
22
|
+
me = g.author("ada")
|
|
23
|
+
inv = me.investigation("Treatment effect", "Does the treatment group score higher?")["id"]
|
|
24
|
+
run = me.add("run", inv, kind="agent", name="scores", state="succeeded")["id"]
|
|
25
|
+
r = me.add("result", inv, kind="computed", outcome="positive", summary="The treatment group scored higher in 41 of 50 pairs.",
|
|
26
|
+
values={"higher": {"k": 41, "n": 50}}, links=[{"rel": "produced", "from": run}])["id"]
|
|
27
|
+
c = me.add("claim", inv, statement="The treatment group scores higher in 41 of 50 matched pairs.",
|
|
28
|
+
scope="The 50 matched pairs in the pilot data.",
|
|
29
|
+
measurements=[{"name": "higher", "result": r, "key": "higher", "k": 41, "n": 50}],
|
|
30
|
+
links=[{"rel": "supports", "from": r, "why": "the measurement the claim states"}])["id"]
|
|
31
|
+
return g, inv, c
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reader_of(g):
|
|
35
|
+
"""A fetch that reads ``g`` the way a host reads a served record: through the node route with history."""
|
|
36
|
+
def fetch(url):
|
|
37
|
+
assert url.startswith(RECORD_A + "/api/graph/"), url
|
|
38
|
+
path, _, query = url[len(RECORD_A + "/api/graph"):].partition("?")
|
|
39
|
+
q = {k: [v] for k, v in (kv.split("=", 1) for kv in query.split("&") if kv)}
|
|
40
|
+
status, obj = dispatch(g, "GET", path, query=q)
|
|
41
|
+
assert status == 200, (status, obj)
|
|
42
|
+
return json.loads(json.dumps(obj, default=str))
|
|
43
|
+
return fetch
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_pin_carries_version_hash_and_the_nodes_own_words(a):
|
|
47
|
+
g, inv, c = a
|
|
48
|
+
p = g.pin("CL1", RECORD_A + "/") # a display code resolves; the trailing slash goes
|
|
49
|
+
assert p["record"] == RECORD_A and p["node"] == c and p["version"] == 1
|
|
50
|
+
assert p["hash"] == body_hash(g.get(c)["body"]) and len(p["hash"]) == 64
|
|
51
|
+
assert "41 of 50" in p["says"] and p["citation"].startswith("CL1 (claim) in " + RECORD_A)
|
|
52
|
+
assert p["url"] == f"{RECORD_A}/#node={c}" and p["kind"] == "primary" and p["retrieved"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_b_cites_a_and_the_reference_is_a_line_of_support(a):
|
|
56
|
+
g_a, _, c = a
|
|
57
|
+
g_b = Graph()
|
|
58
|
+
bob = g_b.author("bob")
|
|
59
|
+
inv = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
|
|
60
|
+
s = bob.add("source", inv, **g_a.pin(c, RECORD_A))
|
|
61
|
+
assert s["body"]["version"] == 1 and isinstance(s["body"]["version"], int)
|
|
62
|
+
cl = bob.add("claim", inv, statement="A pilot found the treatment group higher in 41 of 50 matched pairs.",
|
|
63
|
+
scope="The pilot's 50 matched pairs, as recorded in demo-a.", links=[{"rel": "cites", "to": s["id"]}])
|
|
64
|
+
st = D.state(g_b.snapshot(), cl["id"])
|
|
65
|
+
assert st["independent_lines"] == 1 and st["evidence"] == "external"
|
|
66
|
+
refs = g_b.references()
|
|
67
|
+
assert len(refs) == 1 and refs[0]["record"] == RECORD_A and refs[0]["node"] == c and refs[0]["cited_by"] == [cl["id"]]
|
|
68
|
+
assert g_b.references("inv-nothing") == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_verify_against_the_cited_record(a):
|
|
72
|
+
g_a, _, c = a
|
|
73
|
+
fields = g_a.pin(c, RECORD_A)
|
|
74
|
+
v = verify_reference(fields, fetch=reader_of(g_a))
|
|
75
|
+
assert v["ok"] and v["current_version"] == 1 and not v["revised"] and not v["withdrawn"] and v["lifecycle"] == "active"
|
|
76
|
+
|
|
77
|
+
g_a.author("ada").revise(c, "the scope was too narrow", scope="The 50 matched pairs in the pilot data, both sites.")
|
|
78
|
+
v = verify_reference(fields, fetch=reader_of(g_a))
|
|
79
|
+
assert v["ok"] and v["revised"] and v["current_version"] == 2 # what was read still stands as read
|
|
80
|
+
|
|
81
|
+
g_a.author("ada").withdraw(c, "the pairs were matched after the scores were seen")
|
|
82
|
+
v = verify_reference(fields, fetch=reader_of(g_a))
|
|
83
|
+
assert v["ok"] and v["withdrawn"] and v["lifecycle"] == "withdrawn"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_rewritten_body_or_a_wrong_pin_fails(a):
|
|
87
|
+
g_a, _, c = a
|
|
88
|
+
fields = g_a.pin(c, RECORD_A)
|
|
89
|
+
bad = {**fields, "hash": "0" * 64}
|
|
90
|
+
v = verify_reference(bad, fetch=reader_of(g_a))
|
|
91
|
+
assert not v["ok"] and "hashes to" in v["problem"]
|
|
92
|
+
v = verify_reference({**fields, "version": 7}, fetch=reader_of(g_a))
|
|
93
|
+
assert not v["ok"] and "version 7 does not exist" in v["problem"]
|
|
94
|
+
v = verify_reference({**fields, "node": "n-nowhere"}, fetch=reader_of(g_a))
|
|
95
|
+
assert not v["ok"] and "problem" in v
|
|
96
|
+
|
|
97
|
+
def down(url):
|
|
98
|
+
raise OSError("connection refused")
|
|
99
|
+
v = verify_reference(fields, fetch=down)
|
|
100
|
+
assert not v["ok"] and v["problem"].startswith("could not read the record")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_the_schema_refuses_half_a_reference(a):
|
|
104
|
+
g_a, _, c = a
|
|
105
|
+
g_b = Graph()
|
|
106
|
+
bob = g_b.author("bob")
|
|
107
|
+
inv = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
|
|
108
|
+
p = g_a.pin(c, RECORD_A)
|
|
109
|
+
with pytest.raises(GraphError) as e:
|
|
110
|
+
bob.add("source", inv, **{k: v for k, v in p.items() if k != "hash"})
|
|
111
|
+
assert "missing: hash" in str(e.value)
|
|
112
|
+
with pytest.raises(GraphError) as e:
|
|
113
|
+
bob.add("source", inv, **{**p, "hash": "nope"})
|
|
114
|
+
assert "64-hex" in str(e.value)
|
|
115
|
+
with pytest.raises(GraphError) as e:
|
|
116
|
+
bob.add("source", inv, **{**p, "version": 1.5})
|
|
117
|
+
assert "whole number" in str(e.value)
|
|
118
|
+
with pytest.raises(GraphError):
|
|
119
|
+
bob.add("source", inv, **{**p, "record": "ftp://mesh/q/a"})
|
|
120
|
+
plain = bob.add("source", inv, citation="A plain outside source, cited by its DOI.", doi="10.1000/xyz")
|
|
121
|
+
assert "record" not in plain["body"] and g_b.references() == []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_check_fields_and_normalize_record():
|
|
125
|
+
assert normalize_record("HTTPS://Mesh.example.org/q/a/?x=1#node=n") == "https://Mesh.example.org/q/a"
|
|
126
|
+
assert check_fields({"citation": "x"}) == {"citation": "x"}
|
|
127
|
+
out = check_fields({"record": "https://m/q/a", "node": " n1 ", "version": 2.0, "hash": "A" * 64})
|
|
128
|
+
assert out["version"] == 2 and out["node"] == "n1" and out["hash"] == "a" * 64
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_the_routes_pin_and_list(a, tmp_path):
|
|
132
|
+
g_a, inv, c = a
|
|
133
|
+
srv = make_server(g_a, "127.0.0.1", 0)
|
|
134
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
135
|
+
base = f"http://127.0.0.1:{srv.server_address[1]}"
|
|
136
|
+
try:
|
|
137
|
+
with urllib.request.urlopen(f"{base}/api/graph/pin/{c}?record={RECORD_A}") as r:
|
|
138
|
+
p = json.load(r)
|
|
139
|
+
assert p["hash"] == body_hash(g_a.get(c)["body"]) and p["version"] == 1
|
|
140
|
+
try:
|
|
141
|
+
urllib.request.urlopen(f"{base}/api/graph/pin/{c}")
|
|
142
|
+
assert False, "record= is required"
|
|
143
|
+
except urllib.error.HTTPError as e:
|
|
144
|
+
assert e.code == 400
|
|
145
|
+
g_b = Graph(str(tmp_path / "b.hypgraph"))
|
|
146
|
+
bob = g_b.author("bob")
|
|
147
|
+
inv_b = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
|
|
148
|
+
bob.add("source", inv_b, **p)
|
|
149
|
+
status, obj = dispatch(g_b, "GET", "/references", query={"inv": [inv_b]})
|
|
150
|
+
assert status == 200 and [x["node"] for x in obj["references"]] == [c]
|
|
151
|
+
# verified over real HTTP, the way a mesh or another host does it
|
|
152
|
+
v = verify_reference({**p, "record": base})
|
|
153
|
+
assert v["ok"] and not v["revised"]
|
|
154
|
+
finally:
|
|
155
|
+
srv.shutdown()
|
|
156
|
+
g_b.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|