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.
Files changed (100) hide show
  1. {hypgraph-0.7.7 → hypgraph-0.7.8}/CHANGELOG.md +13 -0
  2. {hypgraph-0.7.7 → hypgraph-0.7.8}/PKG-INFO +1 -1
  3. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/spec.md +1 -1
  4. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/__init__.py +3 -2
  5. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/graph.py +15 -0
  6. hypgraph-0.7.8/src/hypgraph/references.py +173 -0
  7. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/schema.py +4 -2
  8. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/server.py +11 -0
  9. hypgraph-0.7.8/tests/test_references.py +156 -0
  10. {hypgraph-0.7.7 → hypgraph-0.7.8}/.gitignore +0 -0
  11. {hypgraph-0.7.7 → hypgraph-0.7.8}/AGENTS.md +0 -0
  12. {hypgraph-0.7.7 → hypgraph-0.7.8}/CITATION.cff +0 -0
  13. {hypgraph-0.7.7 → hypgraph-0.7.8}/LICENSE +0 -0
  14. {hypgraph-0.7.7 → hypgraph-0.7.8}/NOTICE +0 -0
  15. {hypgraph-0.7.7 → hypgraph-0.7.8}/README.md +0 -0
  16. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/PLAN.md +0 -0
  17. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  18. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  19. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/profile-workflows-plan.md +0 -0
  20. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/review-2026-10-24.md +0 -0
  21. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/site-plan.md +0 -0
  22. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/README.md +0 -0
  24. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/knowledge.md +0 -0
  25. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/optimization.md +0 -0
  26. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/scientific.md +0 -0
  27. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/software.md +0 -0
  28. {hypgraph-0.7.7 → hypgraph-0.7.8}/docs/workflows/trading.md +0 -0
  29. {hypgraph-0.7.7 → hypgraph-0.7.8}/pyproject.toml +0 -0
  30. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/__main__.py +0 -0
  31. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/bundle.py +0 -0
  32. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/checks.py +0 -0
  33. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/cli.py +0 -0
  34. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/coord.py +0 -0
  35. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/demos/__init__.py +0 -0
  36. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/demos/broad_street.py +0 -0
  37. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/derive.py +0 -0
  38. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/language.py +0 -0
  39. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/mcp.py +0 -0
  40. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/plan.py +0 -0
  41. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/policy.py +0 -0
  42. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/profiles.py +0 -0
  43. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/refusals.py +0 -0
  44. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/report_check.py +0 -0
  45. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/skill/SKILL.md +0 -0
  46. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/stats.py +0 -0
  47. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/store.py +0 -0
  48. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/__init__.py +0 -0
  49. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.css +0 -0
  50. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.js +0 -0
  51. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.css +0 -0
  52. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.js +0 -0
  53. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.svg +0 -0
  54. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/index.html +0 -0
  55. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  56. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  57. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  58. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  59. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  60. {hypgraph-0.7.7 → hypgraph-0.7.8}/src/hypgraph/workflow.py +0 -0
  61. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_accept_work_kind.py +0 -0
  62. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_bound_support.py +0 -0
  63. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_bundle_telemetry.py +0 -0
  64. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_cancel_releases_lease.py +0 -0
  65. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_cited_and_failed_support.py +0 -0
  66. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_coord.py +0 -0
  67. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_d10.py +0 -0
  68. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_engine.py +0 -0
  69. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_engine_fixes.py +0 -0
  70. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_incremental.py +0 -0
  71. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_link_translate.py +0 -0
  72. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_log_and_check.py +0 -0
  73. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_measure_questions.py +0 -0
  74. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_opposed_answers.py +0 -0
  75. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_option_lead.py +0 -0
  76. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_order_detail.py +0 -0
  77. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_profiles.py +0 -0
  78. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_profiles_041.py +0 -0
  79. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_questions_not_assertions.py +0 -0
  80. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_refusal_friction.py +0 -0
  81. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_refusal_results.py +0 -0
  82. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_refusals.py +0 -0
  83. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_relation_words.py +0 -0
  84. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_report_answers.py +0 -0
  85. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_rerunnable.py +0 -0
  86. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_restates_scale.py +0 -0
  87. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_server_auth.py +0 -0
  88. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_server_hosts.py +0 -0
  89. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_shape_refusals.py +0 -0
  90. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_stats_not_backtests.py +0 -0
  91. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_stats_values.py +0 -0
  92. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_surfaces.py +0 -0
  93. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_telemetry_stream.py +0 -0
  94. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_ui_app.py +0 -0
  95. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_ui_engine.py +0 -0
  96. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_uncorrected_effect.py +0 -0
  97. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_unnamed_inputs.py +0 -0
  98. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_versions_not_figures.py +0 -0
  99. {hypgraph-0.7.7 → hypgraph-0.7.8}/tests/test_vocabulary_friction.py +0 -0
  100. {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.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.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