hypgraph 0.7.12__tar.gz → 0.7.13__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.12 → hypgraph-0.7.13}/CHANGELOG.md +30 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/NOTICE +1 -1
- {hypgraph-0.7.12 → hypgraph-0.7.13}/PKG-INFO +5 -1
- {hypgraph-0.7.12 → hypgraph-0.7.13}/README.md +4 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/PLAN.md +1 -1
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/RELEASE-READINESS.md +12 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/spec.md +38 -14
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/verification.md +19 -4
- hypgraph-0.7.13/examples/frameworks/README.md +70 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/bundle.py +147 -50
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/cli.py +13 -2
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/coord.py +7 -4
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/graph.py +31 -15
- hypgraph-0.7.13/src/hypgraph/store.py +288 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/hypgraph.js +192 -35
- hypgraph-0.7.13/tests/test_framework_examples.py +50 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_surfaces.py +1 -1
- hypgraph-0.7.13/tests/test_verify_complete.py +291 -0
- hypgraph-0.7.12/src/hypgraph/store.py +0 -139
- {hypgraph-0.7.12 → hypgraph-0.7.13}/.gitignore +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/AGENTS.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/CITATION.cff +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/DISCLAIMER.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/LICENSE +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/site-plan.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/pyproject.toml +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/blind.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/references.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/schema.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/server.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_anchoring.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_blind.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_blind_oracles.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_coord.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_d10.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_engine.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_references.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_trust_boundary.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.13 — 2026-10-31: verification checks every row, and examples for agent frameworks
|
|
4
|
+
|
|
5
|
+
- **Verification checks every row, both ways.** A pre-launch audit found that `verify_bundle`, `verify_file` and
|
|
6
|
+
the browser's `verifyBundle` only checked that each row present matched an event. A bundle or graph file with a
|
|
7
|
+
withdrawal, a version, a link, a lease or a whole table removed still verified, so a withdrawn claim could come
|
|
8
|
+
back to life through `load()`; changed authors, times, link ends, reasons and duplicated rows passed too. Now every
|
|
9
|
+
row must be written by an event and match it field by field, every row an event wrote must be present, and no key
|
|
10
|
+
may appear twice, in Python, SQLite and the browser alike (`tests/test_verify_complete.py`, which also checks the
|
|
11
|
+
browser verifier agrees on every case).
|
|
12
|
+
- **Events hash every field of the row they write.** New events carry `reason_sha` (create, revise), `why_sha`
|
|
13
|
+
(link, retract, withdraw), `note_sha` (leases), a review's `code` and `inv`, and `as` when the importer credits
|
|
14
|
+
another author. Only the importer may. Older records still verify: what their events don't cover is reported as
|
|
15
|
+
`unchecked`, field by field, instead of passing silently.
|
|
16
|
+
- **Derived views are checked; standing is labelled.** A bundle's `derived` views must agree with the verified rows
|
|
17
|
+
on every fact the rows fix (nodes, live links, titles, authors, times, withdrawn or not); an edited view fails.
|
|
18
|
+
Standing can't be checked from the rows, so the result says `standing: {checked: false}`; `hypgraph verify
|
|
19
|
+
--standing` re-derives it with the same engine version and fails on a difference. The explorer shows a separate
|
|
20
|
+
"standing as exported" badge beside the verified one, and a banner over a record that doesn't verify.
|
|
21
|
+
- **The verify page takes a published head** and lists the fields an older record doesn't cover.
|
|
22
|
+
|
|
23
|
+
- **A LangChain example** (`examples/frameworks/langchain_agent.py`): `create_agent` with the record's tools from a
|
|
24
|
+
`MultiServerMCPClient` session, the same story as the others. It runs in the `frameworks` CI job.
|
|
25
|
+
- **Examples for agent frameworks** (`examples/frameworks/`): LangGraph, PydanticAI, CrewAI, the OpenAI Agents SDK
|
|
26
|
+
and the Claude Agent SDK, each connected to a record through `hypgraph mcp`. All five tell the same story: the
|
|
27
|
+
agent claims a 1.2-point difference is significant, the record refuses with the arithmetic, and the agent writes
|
|
28
|
+
the honest claim, which is accepted. They run offline with a scripted model through each framework's own
|
|
29
|
+
tool-calling path, or with `--real` on a real model. The README covers the one setting each framework needs so a
|
|
30
|
+
refusal reaches the model instead of ending the run. A new CI job runs them all (the Claude Agent SDK, which has no
|
|
31
|
+
scripted mode, gets a wiring check).
|
|
32
|
+
|
|
3
33
|
## 0.7.12 — 2026-10-31: the trust boundary, verification and docs, reviewed before launch
|
|
4
34
|
|
|
5
35
|
A pre-launch review of who may write as whom, through every interface. Each fix has a test that failed first
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.13
|
|
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/
|
|
@@ -180,6 +180,10 @@ the record from its checker until it is returned. The method is in [AGENTS.md](h
|
|
|
180
180
|
[llms.txt](https://hypgraph.com/llms.txt). What the server enforces, and what it leaves to you, is in
|
|
181
181
|
[SECURITY.md](https://github.com/voodoomods/hypgraph/blob/main/SECURITY.md).
|
|
182
182
|
|
|
183
|
+
Runnable examples for LangChain, LangGraph, PydanticAI, CrewAI, the OpenAI Agents SDK and the Claude Agent SDK, each
|
|
184
|
+
the same story wired a different way, are in
|
|
185
|
+
[examples/frameworks](https://github.com/voodoomods/hypgraph/tree/main/examples/frameworks).
|
|
186
|
+
|
|
183
187
|
## Wire it into your system
|
|
184
188
|
|
|
185
189
|
The core is one Python package with no dependencies. You decide who is trusted to do what:
|
|
@@ -148,6 +148,10 @@ the record from its checker until it is returned. The method is in [AGENTS.md](h
|
|
|
148
148
|
[llms.txt](https://hypgraph.com/llms.txt). What the server enforces, and what it leaves to you, is in
|
|
149
149
|
[SECURITY.md](https://github.com/voodoomods/hypgraph/blob/main/SECURITY.md).
|
|
150
150
|
|
|
151
|
+
Runnable examples for LangChain, LangGraph, PydanticAI, CrewAI, the OpenAI Agents SDK and the Claude Agent SDK, each
|
|
152
|
+
the same story wired a different way, are in
|
|
153
|
+
[examples/frameworks](https://github.com/voodoomods/hypgraph/tree/main/examples/frameworks).
|
|
154
|
+
|
|
151
155
|
## Wire it into your system
|
|
152
156
|
|
|
153
157
|
The core is one Python package with no dependencies. You decide who is trusted to do what:
|
|
@@ -109,7 +109,7 @@ claim's derived standing.
|
|
|
109
109
|
Multi-writer sync between graphs (bundles cover sharing), authentication (the host's job; the HTTP server binds to
|
|
110
110
|
loopback and is read-only unless started with `--writable`), and a hosted service.
|
|
111
111
|
|
|
112
|
-
## Status at 0.7.
|
|
112
|
+
## Status at 0.7.13 (2026-10-31)
|
|
113
113
|
|
|
114
114
|
All seven phases are done: the library, bundles, the server and UI, the MCP server, the website, Pegasus running on
|
|
115
115
|
the package, and releases to PyPI from tags with trusted publishing. What came after the plan, by release, is in
|
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
#61 (docs and release engineering). Everything marked as tested was run; the commands are in each PR. Nothing here
|
|
5
5
|
was taken from a changelog without being checked against the code.*
|
|
6
6
|
|
|
7
|
+
> **Superseded in part (2026-10-31, later the same day).** A second audit found that verification checked rows
|
|
8
|
+
> against events in one direction only: a bundle or graph file with a withdrawal, a version, an edge or a whole
|
|
9
|
+
> table removed still verified, as did changed authors, times, link ends and reasons, and edited derived views. That
|
|
10
|
+
> was a confirmed integrity defect, so the GO below didn't hold for 0.7.12. The fix (exhaustive row checks in the
|
|
11
|
+
> Python, SQLite and browser verifiers, events that hash every row field, derived views checked against the rows,
|
|
12
|
+
> standing reported as not covered) is in the pull request that adds `tests/test_verify_complete.py`, and ships as
|
|
13
|
+
> 0.7.13. The recommendation stands again once that is merged with green CI and released.
|
|
14
|
+
>
|
|
15
|
+
> **Where that stands.** #66 is merged with green CI (17 of 17 checks); on main the suite is 386 passed, and
|
|
16
|
+
> hypswarm, hypmesh and hypdmz pass on it. Released as 0.7.13. The required-checks setting (condition 3 below) is
|
|
17
|
+
> still open, for the operator.
|
|
18
|
+
|
|
7
19
|
## Recommendation: CONDITIONAL GO
|
|
8
20
|
|
|
9
21
|
HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
|
|
@@ -78,19 +78,35 @@ where `prev` is the previous event's hash (64 zeros for the first). Kinds and pa
|
|
|
78
78
|
|
|
79
79
|
| kind | ref | payload |
|
|
80
80
|
|---|---|---|
|
|
81
|
-
| `create` | the new node | `{type, code, inv, v: 1, body_sha}`; `body_sha` = sha256 of the version's stored body string |
|
|
82
|
-
| `revise` | the node | `{v, reason, body_sha}` |
|
|
83
|
-
| `review` | the reviewed node | `{review: <review node id>, verdict, on_v, official, body_sha}` |
|
|
84
|
-
| `link` | the edge's source | `{edge, rel, dst, src_v, dst_v, data_sha}`; `data_sha` = sha256 of the stored edge data string |
|
|
85
|
-
| `retract` | the edge's source | `{edge}` |
|
|
86
|
-
| `withdraw` | the node | `{why}` |
|
|
87
|
-
| `lease_take` / `lease_release` | the node | `{task, expires}` |
|
|
88
|
-
|
|
89
|
-
Any event may carry `task` in its payload: the unit of work that wrote it.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
81
|
+
| `create` | the new node | `{type, code, inv, v: 1, body_sha, reason_sha, as?}`; `body_sha` = sha256 of the version's stored body string, `reason_sha` of its stored reason |
|
|
82
|
+
| `revise` | the node | `{v, reason, body_sha, reason_sha}`; `reason` is the first 300 characters |
|
|
83
|
+
| `review` | the reviewed node | `{review: <review node id>, verdict, on_v, official, body_sha, code, inv, as?}` |
|
|
84
|
+
| `link` | the edge's source | `{edge, rel, dst, src_v, dst_v, data_sha, why_sha}`; `data_sha` = sha256 of the stored edge data string, `why_sha` of its stored reason |
|
|
85
|
+
| `retract` | the edge's source | `{edge, why_sha}` |
|
|
86
|
+
| `withdraw` | the node | `{why, why_sha, as?}`; `why` is the first 300 characters, `why_sha` covers the stored reason |
|
|
87
|
+
| `lease_take` / `lease_release` | the node | `{task, expires, note_sha, for?}`; `for` = the agent holding the lease when it isn't the actor |
|
|
88
|
+
|
|
89
|
+
Any event may carry `task` in its payload: the unit of work that wrote it. `as` appears only when the importer
|
|
90
|
+
credits a node or withdrawal to another author; from any other actor it is invalid.
|
|
91
|
+
|
|
92
|
+
**Verifying** a record means recomputing every hash in order and checking each `prev`, then checking the rows
|
|
93
|
+
against the events **both ways**: every row was written by an event and matches it field by field, every row an
|
|
94
|
+
event wrote is present, and no key appears twice. The fields each event fixes:
|
|
95
|
+
|
|
96
|
+
| table | key | must equal |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `nodes` | `create.ref`, `review.payload.review` | `type`, `code`, `inv` from the payload (a review: type `review`); `created_at` = the event's `at`; `created_by` = the actor, or `as` |
|
|
99
|
+
| `versions` | node + `v` | `at`, `by` = the actor, sha256 of `body` = `body_sha`, sha256 of `reason` = `reason_sha` (a review's: empty) |
|
|
100
|
+
| `edges` | `payload.edge` | `src` = ref, `rel`, `dst`, `src_v`, `dst_v`, `at`, `by` = the actor, sha256 of `data` = `data_sha`, of `why` = `why_sha` |
|
|
101
|
+
| `withdrawals` | the node | `at`, `by` = the actor or `as`, sha256 of `why` = `why_sha` |
|
|
102
|
+
| `retractions` | `payload.edge` | `at`, `by` = the actor, sha256 of `why` = `why_sha` |
|
|
103
|
+
| `leases` | in id order, one per lease event in order | `node` = ref, `agent` = `for` or the actor, `task`, `kind` (`take` / `release`), `at`, `expires`, sha256 of `note` = `note_sha` |
|
|
104
|
+
|
|
105
|
+
Records written before these fields existed (before 0.7.13) carry events without `reason_sha`, `why_sha`,
|
|
106
|
+
`note_sha`, `as`, or a review's `code` and `inv`. A verifier checks what those events do carry (a revision's or a
|
|
107
|
+
withdrawal's first 300 characters, a lease note given in full, a review's investigation from its target) and
|
|
108
|
+
reports every other non-empty field as **not covered**, by table and field, rather than passing it silently. Without
|
|
109
|
+
`as`, a row the importer wrote may credit any author; that is reported too.
|
|
94
110
|
|
|
95
111
|
## 4. Bundles — `hypgraph/1-bundle`
|
|
96
112
|
|
|
@@ -108,7 +124,7 @@ strings and compares.
|
|
|
108
124
|
"versions": [{"node": "…", "v": 1, "at": "…", "by": "…", "reason": "", "body": "<stored canonical string>"}],
|
|
109
125
|
"edges": [{"id": 1, "src": "…", "src_v": 1, "rel": "…", "dst": "…", "dst_v": 1, "data": "<stored string>", "…": "…"}],
|
|
110
126
|
"withdrawals": [], "retractions": [], "leases": [],
|
|
111
|
-
"derived": {"…": "
|
|
127
|
+
"derived": {"…": "views for viewers; their facts are checked against the rows, their standing is not"}}
|
|
112
128
|
```
|
|
113
129
|
|
|
114
130
|
A **scoped** bundle (one investigation out of a shared record) replaces events outside it with
|
|
@@ -123,6 +139,14 @@ or a prefix of at least 16 hex characters) fails unless the chain ends there: `h
|
|
|
123
139
|
`verify_bundle(b, head=HEX)`. A bundle without a `head` field is checked for consistency only. How a head is
|
|
124
140
|
published and signed, and what each check does and doesn't show: [verification.md](verification.md).
|
|
125
141
|
|
|
142
|
+
**Derived views.** `derived` is a convenience for viewers, and a viewer shows it beside a verified chain, so a
|
|
143
|
+
verifier must check every fact in it that the rows fix: which investigations it covers (the scope's, or every
|
|
144
|
+
investigation node), which nodes each investigation's graph shows (all of them, unless capped at 5,000) and which
|
|
145
|
+
live (unretracted) links among them, each node's `type`, `code`, `inv`, `by`, `at`, `v`, `title` and `kind`, whether
|
|
146
|
+
it is shown as withdrawn, and each link's fields. **Standing** (review state, support, at-risk, the sentence) can't
|
|
147
|
+
be checked from the rows alone: it is what the exporting engine derived. A verifier reports it as not checked, or
|
|
148
|
+
re-derives it with the same engine version (`hypgraph verify FILE --standing`) and compares.
|
|
149
|
+
|
|
126
150
|
## 5. Derived standing (normative)
|
|
127
151
|
|
|
128
152
|
Implementations must derive these, never store them:
|
|
@@ -8,7 +8,7 @@ somewhere its keeper can't change.
|
|
|
8
8
|
|
|
9
9
|
| | What it shows | How | What it doesn't show |
|
|
10
10
|
|---|---|---|---|
|
|
11
|
-
| **1. Internal consistency** | The chain of events is unbroken
|
|
11
|
+
| **1. Internal consistency** | The chain of events is unbroken; every node, version, link, withdrawal, retraction and lease was written by an event and matches it field by field; no row an event wrote is missing; and the facts a viewer shows beside it (which nodes and links, titles, authors, times, withdrawn or not) agree with those rows. | `hypgraph verify study.bundle.json` (or a graph file), `verify_bundle(b)` in Python, `HypGraph.verifyBundle` in the browser | That it wasn't rewritten. Whoever can write the record can change it, recompute every hash, or cut it short, and it still verifies. |
|
|
12
12
|
| **2. Not rewritten since it was published** | The record ends at the head that was published. Any change to anything before it, or a record cut short, ends somewhere else. | `hypgraph verify study.bundle.json --head <published head>`, `verify_bundle(b, head=...)`, `anchored(g.verify(), head)` | Who published that head. That's the job of where it was published (below). |
|
|
13
13
|
| **3. Provenance of the evidence** | Who recorded what, and how: a lab-recorded run (only the instrument author files those) or an agent's report, its inputs and outputs by hash, which lines of support are independent. | The record's derived standing: `hypgraph show CL1 --why`, the report | That the instrument's own files are honest. A host decides who the instrument is (the policy). |
|
|
14
14
|
| **4. That a claim is true** | Nothing. | Reviews, replications, separate implementations, new data: the evidence the record keeps and ranks | — |
|
|
@@ -24,11 +24,12 @@ claims in it stand exactly as well as the record derives, and no better.
|
|
|
24
24
|
$ hypgraph demo && hypgraph export -o study.bundle.json
|
|
25
25
|
Wrote study.bundle.json: 59 events, 24 nodes
|
|
26
26
|
$ hypgraph verify study.bundle.json
|
|
27
|
-
✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it.
|
|
27
|
+
✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it, and none is missing.
|
|
28
28
|
head 565123f3a7ac06ed18ab555935fde0457ebb6a302dc188fc83dbf36af579623f
|
|
29
29
|
consistent with itself; to rule out a rewrite, check it against a published head (--head)
|
|
30
|
+
standing not checked: standing (review state, support, at-risk, the sentence) is what the exporting engine derived: …
|
|
30
31
|
$ hypgraph verify study.bundle.json --head 565123f3a7ac06ed
|
|
31
|
-
✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it.
|
|
32
|
+
✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it, and none is missing.
|
|
32
33
|
head 565123f3a7ac06ed18ab555935fde0457ebb6a302dc188fc83dbf36af579623f
|
|
33
34
|
matches the anchored head: not rewritten or cut short since it was published
|
|
34
35
|
```
|
|
@@ -67,6 +68,18 @@ SHA-256 over strings and compares: no JSON canonicalisation to reimplement. The
|
|
|
67
68
|
and the verifier's rules, is in [spec.md](spec.md). The site checks bundles in the browser with WebCrypto, from a
|
|
68
69
|
separate implementation.
|
|
69
70
|
|
|
71
|
+
## Standing, and older records
|
|
72
|
+
|
|
73
|
+
- **Standing** (review state, support, at-risk, the one-line summary) is derived, and a bundle carries the standing
|
|
74
|
+
its exporter derived. The chain doesn't cover it. `hypgraph verify study.bundle.json --standing` replays the
|
|
75
|
+
verified rows and derives it again; it fails if the result differs. Standing rules change between releases, so it
|
|
76
|
+
only compares a bundle exported by the same version, and says so otherwise. A graph file has no stored standing:
|
|
77
|
+
it is derived on every read.
|
|
78
|
+
- **Records written before 0.7.13** have events that don't hash a few row fields (a link's reason, a retraction's
|
|
79
|
+
reason, a lease note, a revision's full reason, the credited author of an importer's node). The verifier checks
|
|
80
|
+
what those events carry and lists the rest as *not covered*, with a count per field. Rewriting one of those fields
|
|
81
|
+
in an old record isn't caught; new writes cover them all.
|
|
82
|
+
|
|
70
83
|
## Scoped bundles and references
|
|
71
84
|
|
|
72
85
|
- `hypgraph export --inv INV` writes one investigation. Events outside it are redacted to their hash, so the chain
|
|
@@ -79,5 +92,7 @@ separate implementation.
|
|
|
79
92
|
|
|
80
93
|
`dev/e2e_install.py` installs HypGraph into a fresh environment and walks the whole path: a record, a claim accepted
|
|
81
94
|
and one refused, a review, an export, and verification in another directory with no graph file, on its own and
|
|
82
|
-
against the head, and failing on an edited copy. `tests/
|
|
95
|
+
against the head, and failing on an edited copy. `tests/test_verify_complete.py` drops, duplicates, empties and
|
|
96
|
+
edits every table and every field of a bundle and of a graph file, and edits the derived views: each one fails, in
|
|
97
|
+
Python and in the browser verifier alike. `tests/test_anchoring.py` rewrites a record (changing a claim and
|
|
83
98
|
recomputing every hash) and cuts one short: both verify on their own, and both fail against the published head.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# HypGraph with agent frameworks
|
|
2
|
+
|
|
3
|
+
One story, told once per framework, so the difference between the files is only the wiring.
|
|
4
|
+
|
|
5
|
+
An eval harness has compared two models on the same 1,000 held-out questions: model A got 812 right, model B 800.
|
|
6
|
+
An agent, connected to the record over MCP as the author `analyst`, is asked whether A is more accurate. It writes
|
|
7
|
+
"significantly more accurate". The record refuses that with the arithmetic (p ≈ 0.5) and says what to write instead.
|
|
8
|
+
The agent writes the honest claim, and it is accepted. Afterwards the script reads the record in Python and prints
|
|
9
|
+
what was refused, what was accepted, and the refusal log.
|
|
10
|
+
|
|
11
|
+
The framework doesn't need to know HypGraph exists. It sees MCP tools, and the record does the checking.
|
|
12
|
+
|
|
13
|
+
| Framework | File | Install | MCP wiring |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| LangChain | [langchain_agent.py](langchain_agent.py) | `langchain langchain-mcp-adapters` | `MultiServerMCPClient` session → `create_agent` |
|
|
16
|
+
| LangGraph | [langgraph_agent.py](langgraph_agent.py) | `langgraph langchain-mcp-adapters` | `load_mcp_tools(session)` into a `ToolNode` |
|
|
17
|
+
| PydanticAI | [pydantic_ai_agent.py](pydantic_ai_agent.py) | `"pydantic-ai-slim[mcp]"` | `MCPToolset(StdioTransport(...))` |
|
|
18
|
+
| CrewAI | [crewai_agent.py](crewai_agent.py) | `crewai` | `Agent(mcps=[MCPServerStdio(...)])` |
|
|
19
|
+
| OpenAI Agents SDK | [openai_agents_sdk.py](openai_agents_sdk.py) | `openai-agents` | `Agent(mcp_servers=[MCPServerStdio(...)])` |
|
|
20
|
+
| Claude Agent SDK | [claude_sdk_agent.py](claude_sdk_agent.py) | `claude-agent-sdk` | `ClaudeAgentOptions(mcp_servers={...})` |
|
|
21
|
+
|
|
22
|
+
Every example also needs `pip install "hypgraph[mcp]"`. [_story.py](_story.py) holds what they share: the record
|
|
23
|
+
before the agent arrives, the task, the server command, and the check at the end.
|
|
24
|
+
|
|
25
|
+
## Run them
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd examples/frameworks
|
|
29
|
+
python langgraph_agent.py # scripted model: offline, free, the same every time
|
|
30
|
+
python langgraph_agent.py --real --model gpt-4.1-mini
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
With no flags, a scripted model plays a careful agent's moves through the framework's own tool-calling path:
|
|
34
|
+
read the overview, overclaim, read the refusal, write the honest claim. That shows the wiring works, including the
|
|
35
|
+
part that matters most, a refused write getting back to the model. It can't show a model reasoning. `--real` uses a
|
|
36
|
+
real model (`OPENAI_API_KEY`, or whatever the framework's model string needs; `--model` or `MODEL` picks it). A real
|
|
37
|
+
model passes if it got a claim accepted and fixed every refusal, because a careful model may write the honest claim
|
|
38
|
+
the first time.
|
|
39
|
+
|
|
40
|
+
`--db FILE` keeps the record, so you can open it afterwards with `hypgraph --db FILE serve`.
|
|
41
|
+
|
|
42
|
+
The Claude Agent SDK drives Claude Code itself and has no scripted mode. `python claude_sdk_agent.py` is a real run
|
|
43
|
+
(Claude Code signed in, or `ANTHROPIC_API_KEY`; `--model` takes `haiku`, `sonnet` or `opus`). `--check` starts the
|
|
44
|
+
record's server from the exact options the SDK would use and confirms every allowed tool exists. That needs no model.
|
|
45
|
+
|
|
46
|
+
## What to copy
|
|
47
|
+
|
|
48
|
+
- **One server per author.** `hypgraph --db FILE --as analyst mcp` writes as `analyst` and nothing else. The agent
|
|
49
|
+
can't pass another name, and its verdicts count only if the investigation names it as a reviewer. Give each agent
|
|
50
|
+
in a team its own server, with its own author.
|
|
51
|
+
- **Let a refusal reach the model.** A refused write is an MCP tool error whose text names the problem and the fix
|
|
52
|
+
(for example `[SIGNIFICANCE_NOT_SHOWN]`). If the framework ends the run on a tool error, the agent never sees it:
|
|
53
|
+
- LangChain: `load_mcp_tools(session, handle_tool_errors=True)`, the adapter's default, returns it as the tool's
|
|
54
|
+
output, so `create_agent` needs nothing more. Hold one `client.session(...)` for the run: `client.get_tools()`
|
|
55
|
+
alone starts a server process for every tool call.
|
|
56
|
+
- LangGraph: `ToolNode(tools, handle_tool_errors=True)`.
|
|
57
|
+
- PydanticAI: the default (`tool_error_behavior="retry"`) sends it back as a retry prompt. `max_retries` bounds it.
|
|
58
|
+
- CrewAI and the OpenAI Agents SDK hand it back as the tool's output by default.
|
|
59
|
+
- Claude Code shows tool errors to the model.
|
|
60
|
+
- **Keep the agent on the record.** The Claude example passes `tools=[]` (no shell, no file edits),
|
|
61
|
+
`strict_mcp_config=True` (no other MCP servers) and pre-approves only the record's tools.
|
|
62
|
+
- **CrewAI names MCP tools after the command line** (`<command>_<args>_<tool>`, hashed past 64 characters). The
|
|
63
|
+
example runs plain `hypgraph mcp` with `HYPGRAPH_DB` and `HYPGRAPH_AUTHOR` in its environment, so the tools come out
|
|
64
|
+
as `hypgraph_mcp_add` and so on.
|
|
65
|
+
|
|
66
|
+
Frameworks that take plain Python functions as tools can skip MCP. Call `Graph` directly and return the refusal text
|
|
67
|
+
from `GraphError`; see [hypgraph.com/engineers](https://hypgraph.com/engineers/#connect).
|
|
68
|
+
|
|
69
|
+
Tested with langchain 1.4, langgraph 1.2, pydantic-ai 2.x, crewai 1.15, openai-agents 0.20 and claude-agent-sdk 0.2 (the scripted
|
|
70
|
+
runs, and the Claude `--check`, run in CI).
|
|
@@ -16,7 +16,7 @@ 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.13"
|
|
20
20
|
|
|
21
21
|
from .bundle import anchored, verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -11,7 +11,8 @@ SHA-256 over strings plus a few lookups — any language can do it (the site doe
|
|
|
11
11
|
"versions": [{"node", "v", "at", "by", "reason", "body"}], # body = the stored canonical string
|
|
12
12
|
"edges": [{"id", "src", "src_v", "rel", "dst", "dst_v", "data", "at", "by", "why"}], # data likewise
|
|
13
13
|
"withdrawals": [...], "retractions": [...], "leases": [...],
|
|
14
|
-
"derived": {...}, #
|
|
14
|
+
"derived": {...}, # views for viewers: facts checked against the rows,
|
|
15
|
+
# standing not (verify_bundle(..., standing=True) re-derives)
|
|
15
16
|
"telemetry": {"source", "covered_by_chain": false, "events": [...]}} # optional: a swarm's telemetry sidecar
|
|
16
17
|
|
|
17
18
|
A scoped bundle (one investigation out of a shared graph) redacts the events outside it to {seq, hash, prev}: their
|
|
@@ -33,7 +34,7 @@ from pathlib import Path
|
|
|
33
34
|
from typing import TYPE_CHECKING, Any
|
|
34
35
|
|
|
35
36
|
from . import __version__
|
|
36
|
-
from .store import GENESIS, event_body, now
|
|
37
|
+
from .store import GENESIS, ROW_TABLES, check_rows, event_body, now
|
|
37
38
|
|
|
38
39
|
if TYPE_CHECKING:
|
|
39
40
|
from .graph import Graph
|
|
@@ -132,6 +133,7 @@ def derive_views(g: "Graph", invs: list[str]) -> dict:
|
|
|
132
133
|
"profile": profiles.view(pid), "stages": g.stages(i), "completion": g.completion(i),
|
|
133
134
|
"board": g.question_board(i), "orders": g.orders(i)}
|
|
134
135
|
views["verify"] = g.verify()
|
|
136
|
+
views["at"] = g.clock() # the time standing was derived at (leases expire): re-deriving uses it
|
|
135
137
|
return views
|
|
136
138
|
|
|
137
139
|
|
|
@@ -153,18 +155,32 @@ def anchored(res: dict, head: str) -> dict:
|
|
|
153
155
|
return {**res, "anchored": True}
|
|
154
156
|
|
|
155
157
|
|
|
156
|
-
def verify_bundle(b: dict, head: str = "") -> dict:
|
|
157
|
-
"""Check a bundle string-for-string: the chain
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
def verify_bundle(b: dict, head: str = "", *, standing: bool = False) -> dict:
|
|
159
|
+
"""Check a bundle string-for-string: the chain; then the rows against the events, both ways (every row written by
|
|
160
|
+
an event, every event's row present, every recorded field equal); then the derived views against those rows. With
|
|
161
|
+
``head``, also that the chain ends at that published head (see ``anchored``). With ``standing``, re-derive the
|
|
162
|
+
standing from the verified rows and compare it with what the bundle carries (``rederive``)."""
|
|
163
|
+
res = anchored(_verify_bundle(b), head)
|
|
164
|
+
if standing and res.get("ok"):
|
|
165
|
+
res = {**res, "standing": rederive(b)}
|
|
166
|
+
if res["standing"].get("problem"):
|
|
167
|
+
res = {**res, "ok": False, "problem": res["standing"]["problem"]}
|
|
168
|
+
return res
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
STANDING_NOTE = ("standing (review state, support, at-risk, the sentence) is what the exporting engine derived: the "
|
|
172
|
+
"chain doesn't cover it. Re-derive it from the verified rows with `hypgraph verify --standing`.")
|
|
160
173
|
|
|
161
174
|
|
|
162
175
|
def _verify_bundle(b: dict) -> dict:
|
|
163
176
|
if not isinstance(b, dict) or b.get("format") != BUNDLE_FORMAT:
|
|
164
177
|
return {"ok": False, "problem": f"not a {BUNDLE_FORMAT} bundle"}
|
|
165
|
-
prev, checked, red = GENESIS, 0, 0
|
|
166
|
-
|
|
178
|
+
prev, checked, red, seqs = GENESIS, 0, 0, set()
|
|
179
|
+
events: list[dict] = []
|
|
167
180
|
for ev in sorted(b.get("events") or [], key=lambda e: e["seq"]):
|
|
181
|
+
if ev["seq"] in seqs:
|
|
182
|
+
return {"ok": False, "broken_at": ev["seq"], "problem": "two events share a sequence number"}
|
|
183
|
+
seqs.add(ev["seq"])
|
|
168
184
|
if ev.get("redacted"):
|
|
169
185
|
if ev.get("prev") != prev:
|
|
170
186
|
return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (redacted event)"}
|
|
@@ -176,51 +192,19 @@ def _verify_bundle(b: dict) -> dict:
|
|
|
176
192
|
if body.get("prev") != prev:
|
|
177
193
|
return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (prev does not match)"}
|
|
178
194
|
prev, checked = ev["hash"], checked + 1
|
|
179
|
-
|
|
180
|
-
if k == "create":
|
|
181
|
-
creates[ref] = p
|
|
182
|
-
revs[(ref, int(p.get("v", 1)))] = p.get("body_sha")
|
|
183
|
-
elif k == "revise":
|
|
184
|
-
revs[(ref, int(p.get("v")))] = p.get("body_sha")
|
|
185
|
-
elif k == "review":
|
|
186
|
-
reviews[p["review"]] = p
|
|
187
|
-
revs[(p["review"], 1)] = p.get("body_sha")
|
|
188
|
-
elif k == "link":
|
|
189
|
-
links[int(p["edge"])] = p
|
|
190
|
-
elif k == "withdraw":
|
|
191
|
-
withdraws.add(ref)
|
|
192
|
-
elif k == "retract":
|
|
193
|
-
retracts.add(int(p["edge"]))
|
|
195
|
+
events.append(body)
|
|
194
196
|
if b.get("head") and b["head"] != prev:
|
|
195
197
|
return {"ok": False, "problem": "the chain does not end at the bundle's head"}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
for v in b.get("versions") or []:
|
|
204
|
-
want = revs.get((v["node"], v["v"]))
|
|
205
|
-
if want is None:
|
|
206
|
-
return {"ok": False, "problem": f"version {v['node']} v{v['v']} has no event"}
|
|
207
|
-
if _sha(v["body"]) != want:
|
|
208
|
-
return {"ok": False, "problem": f"version {v['node']} v{v['v']} differs from its event"}
|
|
209
|
-
for e in b.get("edges") or []:
|
|
210
|
-
want = links.get(e["id"])
|
|
211
|
-
if want is None:
|
|
212
|
-
return {"ok": False, "problem": f"edge {e['id']} has no event"}
|
|
213
|
-
if (want.get("rel"), want.get("dst"), want.get("dst_v")) != (e["rel"], e["dst"], e["dst_v"]) or \
|
|
214
|
-
_sha(e["data"]) != want.get("data_sha"):
|
|
215
|
-
return {"ok": False, "problem": f"edge {e['id']} differs from its event"}
|
|
216
|
-
for w in b.get("withdrawals") or []:
|
|
217
|
-
if w["node"] not in withdraws:
|
|
218
|
-
return {"ok": False, "problem": f"withdrawal of {w['node']} has no event"}
|
|
219
|
-
for r in b.get("retractions") or []:
|
|
220
|
-
if int(r["edge"]) not in retracts:
|
|
221
|
-
return {"ok": False, "problem": f"retraction of edge {r['edge']} has no event"}
|
|
198
|
+
tables = {t: b.get(t) or [] for t in ROW_TABLES}
|
|
199
|
+
rows = check_rows(events, tables, importer=str((b.get("policy") or {}).get("importer") or "importer"))
|
|
200
|
+
if "problem" in rows:
|
|
201
|
+
return {"ok": False, "problem": rows["problem"]}
|
|
202
|
+
problem = _derived_problem(b, tables)
|
|
203
|
+
if problem:
|
|
204
|
+
return {"ok": False, "problem": f"the derived views contradict the verified record: {problem}"}
|
|
222
205
|
out = {"ok": True, "checked": checked, "head": prev, "complete": red == 0, "redacted": red,
|
|
223
|
-
"nodes": len(
|
|
206
|
+
"nodes": len(tables["nodes"]), "edges": len(tables["edges"]), "unchecked": rows["unchecked"],
|
|
207
|
+
"standing": {"checked": False, "note": STANDING_NOTE}}
|
|
224
208
|
if red:
|
|
225
209
|
out["warning"] = (f"{red} event(s) outside this investigation are redacted: their content can't be checked, so "
|
|
226
210
|
"a change hidden among them (a withdrawal, say) would not show. The full record rules that "
|
|
@@ -228,6 +212,119 @@ def _verify_bundle(b: dict) -> dict:
|
|
|
228
212
|
return out
|
|
229
213
|
|
|
230
214
|
|
|
215
|
+
def _j(v: Any) -> str:
|
|
216
|
+
"""A value in a problem, written as the browser's verifier writes it (JSON.stringify)."""
|
|
217
|
+
return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _title(body: dict) -> str:
|
|
221
|
+
"""A node's title as the engine shows it (Graph.brief)."""
|
|
222
|
+
t = (body.get("statement") or body.get("title") or body.get("text") or body.get("aim") or body.get("summary")
|
|
223
|
+
or body.get("choice") or body.get("citation") or body.get("name") or body.get("path") or body.get("verdict")
|
|
224
|
+
or "")
|
|
225
|
+
return str(t)[:300]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _derived_problem(b: dict, tables: dict) -> str:
|
|
229
|
+
"""The derived views are presentation, but a viewer shows them beside a verified chain: every fact in them that
|
|
230
|
+
the rows fix — which nodes and live links each investigation has, each node's type, code, author, time, version,
|
|
231
|
+
title and whether it is withdrawn — must agree with the verified rows. Standing can't be checked this way."""
|
|
232
|
+
d = b.get("derived")
|
|
233
|
+
if d is None:
|
|
234
|
+
return ""
|
|
235
|
+
if not isinstance(d, dict):
|
|
236
|
+
return "derived is not an object"
|
|
237
|
+
nodes = {n["id"]: n for n in tables["nodes"]}
|
|
238
|
+
latest: dict[str, tuple[int, dict]] = {}
|
|
239
|
+
for v in tables["versions"]:
|
|
240
|
+
if v["v"] > latest.get(v["node"], (0, {}))[0]:
|
|
241
|
+
latest[v["node"]] = (v["v"], json.loads(v["body"]))
|
|
242
|
+
withdrawn = {w["node"] for w in tables["withdrawals"]}
|
|
243
|
+
retracted = {int(r["edge"]) for r in tables["retractions"]}
|
|
244
|
+
live = {int(e["id"]): e for e in tables["edges"] if int(e["id"]) not in retracted}
|
|
245
|
+
scope = str((b.get("scope") or {}).get("inv") or "")
|
|
246
|
+
invs = {scope} if scope else {i for i, n in nodes.items() if n["type"] == "investigation"}
|
|
247
|
+
|
|
248
|
+
def facts(x: dict, where: str) -> str:
|
|
249
|
+
n = nodes.get(x.get("id")) if isinstance(x, dict) else None
|
|
250
|
+
if not n:
|
|
251
|
+
return f"{where} shows {x.get('id') if isinstance(x, dict) else _j(x)}, which the record doesn't hold"
|
|
252
|
+
v, body = latest.get(n["id"], (0, {}))
|
|
253
|
+
want = {"type": n["type"], "code": n["code"], "inv": n["inv"], "by": n["created_by"], "at": n["created_at"],
|
|
254
|
+
"v": v, "title": _title(body), "kind": body.get("kind") or body.get("verdict") or ""}
|
|
255
|
+
for k, w in want.items():
|
|
256
|
+
if k in x and x[k] != w:
|
|
257
|
+
return f"{where} shows {n['code'] or n['id']} with {k} {_j(x[k])}; the record has {_j(w)}"
|
|
258
|
+
life = (x.get("state") or {}).get("lifecycle")
|
|
259
|
+
if life is not None and (life == "withdrawn") != (n["id"] in withdrawn):
|
|
260
|
+
return (f"{where} shows {n['code'] or n['id']} as {life}; the record "
|
|
261
|
+
f"{'withdrew it' if n['id'] in withdrawn else 'has no withdrawal of it'}")
|
|
262
|
+
return ""
|
|
263
|
+
by_inv = d.get("by_inv") or {}
|
|
264
|
+
if set(by_inv) != invs:
|
|
265
|
+
return "the views don't cover the record's investigations"
|
|
266
|
+
for x in d.get("investigations") or []:
|
|
267
|
+
if x.get("id") not in invs:
|
|
268
|
+
return f"the investigation list shows {x.get('id')}, which isn't in the bundle's scope"
|
|
269
|
+
p = facts(x, "the investigation list")
|
|
270
|
+
if p:
|
|
271
|
+
return p
|
|
272
|
+
for inv, v in by_inv.items():
|
|
273
|
+
sg = (v or {}).get("subgraph") or {}
|
|
274
|
+
shown = sg.get("nodes") or []
|
|
275
|
+
for x in shown:
|
|
276
|
+
p = facts(x, f"the graph of {nodes[inv]['code'] or inv}")
|
|
277
|
+
if p:
|
|
278
|
+
return p
|
|
279
|
+
mine = {i for i, n in nodes.items() if n["inv"] == inv or i == inv}
|
|
280
|
+
ids = [x["id"] for x in shown]
|
|
281
|
+
if len(ids) != len(set(ids)) or (set(ids) != mine and not (len(mine) > len(ids) >= 5000)):
|
|
282
|
+
return f"the graph of {nodes[inv]['code'] or inv} doesn't show the nodes the record holds"
|
|
283
|
+
want = {i: e for i, e in live.items() if e["src"] in set(ids) and e["dst"] in set(ids)}
|
|
284
|
+
got = sg.get("edges") or []
|
|
285
|
+
if len(got) != len(want) or {int(e["id"]) for e in got} != set(want):
|
|
286
|
+
return f"the graph of {nodes[inv]['code'] or inv} doesn't show the live links the record holds"
|
|
287
|
+
for e in got:
|
|
288
|
+
r = want[int(e["id"])]
|
|
289
|
+
for k in ("src", "src_v", "rel", "dst", "dst_v", "at", "by", "why"):
|
|
290
|
+
if k in e and e[k] != r[k]:
|
|
291
|
+
return f"the graph shows link {e['id']} with {k} {_j(e[k])}; the record has {_j(r[k])}"
|
|
292
|
+
if "data" in e and e["data"] != json.loads(r["data"]): # as values: 1.0 and 1 are one number
|
|
293
|
+
return f"the graph shows link {e['id']} with other data than the record"
|
|
294
|
+
return ""
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def rederive(b: dict) -> dict:
|
|
298
|
+
"""Replay a complete bundle in memory and derive the standing again, at the time it was derived, with this
|
|
299
|
+
engine: {"checked": True} when it matches what the bundle carries, a "problem" when it doesn't. Standing rules
|
|
300
|
+
change between releases, so only a bundle exported by this version can be compared; others are reported as not
|
|
301
|
+
checked."""
|
|
302
|
+
from .graph import Graph
|
|
303
|
+
from .policy import Policy
|
|
304
|
+
d = b.get("derived")
|
|
305
|
+
if not isinstance(d, dict):
|
|
306
|
+
return {"checked": False, "note": "the bundle carries no derived views"}
|
|
307
|
+
if b.get("generator") != f"hypgraph {__version__}":
|
|
308
|
+
return {"checked": False, "note": f"exported by {b.get('generator') or 'an unknown version'}; standing rules "
|
|
309
|
+
f"differ between releases, so re-derive it with that version"}
|
|
310
|
+
if b.get("redacted_events") or any(e.get("redacted") for e in b.get("events") or []):
|
|
311
|
+
return {"checked": False, "note": "a scoped bundle can't be replayed; re-derive from the full record"}
|
|
312
|
+
at = str(d.get("at") or b.get("exported_at") or now())
|
|
313
|
+
g = Graph(None, policy=Policy.from_dict(b.get("policy") or {}), clock=lambda: at)
|
|
314
|
+
load(g, b)
|
|
315
|
+
fresh = json.loads(json.dumps(derive_views(g, list(d.get("by_inv") or {})), sort_keys=True))
|
|
316
|
+
old = json.loads(json.dumps(d, sort_keys=True))
|
|
317
|
+
for k in ("investigations", "by_inv"):
|
|
318
|
+
if fresh.get(k) != old.get(k):
|
|
319
|
+
where = k
|
|
320
|
+
if k == "by_inv":
|
|
321
|
+
where = next((f"{i}/{view}" for i in old.get(k) or {} for view in (old[k][i] or {})
|
|
322
|
+
if (fresh[k].get(i) or {}).get(view) != old[k][i][view]), k)
|
|
323
|
+
return {"checked": False, "problem": f"the standing the bundle carries differs from what its record "
|
|
324
|
+
f"derives ({where})"}
|
|
325
|
+
return {"checked": True}
|
|
326
|
+
|
|
327
|
+
|
|
231
328
|
def load(g: "Graph", b: dict) -> None:
|
|
232
329
|
"""Replay a complete bundle into an empty graph, row for row — the result verifies exactly as the original."""
|
|
233
330
|
v = verify_bundle(b)
|
|
@@ -276,15 +276,24 @@ def cmd_report(args) -> int:
|
|
|
276
276
|
def cmd_verify(args) -> int:
|
|
277
277
|
target = args.target or args.db
|
|
278
278
|
if str(target).endswith(".json"):
|
|
279
|
-
res = verify_bundle(json.loads(Path(target).read_text(encoding="utf-8")), head=args.head
|
|
279
|
+
res = verify_bundle(json.loads(Path(target).read_text(encoding="utf-8")), head=args.head,
|
|
280
|
+
standing=args.standing)
|
|
280
281
|
else:
|
|
281
282
|
res = anchored(verify_file(target), args.head)
|
|
282
283
|
ok = res.get("ok")
|
|
283
284
|
text = (f"✓ {target}: {res.get('checked')} events verified — chain intact, every row matches the event that "
|
|
284
|
-
f"wrote it.\n head {res.get('head', '')}" if ok else f"✗ {target}: {res.get('problem')}")
|
|
285
|
+
f"wrote it, and none is missing.\n head {res.get('head', '')}" if ok else f"✗ {target}: {res.get('problem')}")
|
|
285
286
|
if ok:
|
|
286
287
|
text += ("\n matches the anchored head: not rewritten or cut short since it was published" if res.get("anchored")
|
|
287
288
|
else "\n consistent with itself; to rule out a rewrite, check it against a published head (--head)")
|
|
289
|
+
if ok and res.get("unchecked"):
|
|
290
|
+
text += ("\n not covered (written before events hashed these fields): "
|
|
291
|
+
+ ", ".join(f"{f} ×{n}" for f, n in sorted(res["unchecked"].items())))
|
|
292
|
+
st = res.get("standing") or {}
|
|
293
|
+
if ok and st.get("checked"):
|
|
294
|
+
text += "\n standing re-derived from the verified rows: it matches what the bundle carries"
|
|
295
|
+
elif ok and st:
|
|
296
|
+
text += "\n standing not checked: " + str(st.get("note") or "")
|
|
288
297
|
if res.get("warning"):
|
|
289
298
|
text += "\n note: " + res["warning"]
|
|
290
299
|
_out(args, res, text)
|
|
@@ -510,6 +519,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
510
519
|
s.add_argument("target", nargs="?", default="")
|
|
511
520
|
s.add_argument("--head", default="", help="the head published for this record (or its first 16+ hex characters): "
|
|
512
521
|
"fail unless the chain ends there")
|
|
522
|
+
s.add_argument("--standing", action="store_true", help="bundles: re-derive the standing from the verified rows and "
|
|
523
|
+
"fail if it differs from what the bundle carries")
|
|
513
524
|
s = cmd("export", cmd_export, "write a verifiable bundle")
|
|
514
525
|
s.add_argument("--inv", default="", help="one investigation (events outside it are redacted)")
|
|
515
526
|
s.add_argument("-o", "--output", default="")
|