hypgraph 0.7.17__tar.gz → 0.7.18__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.17 → hypgraph-0.7.18}/CHANGELOG.md +16 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/PKG-INFO +1 -1
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/PLAN.md +1 -1
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/RELEASE-READINESS.md +12 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/spec.md +13 -5
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/verification.md +5 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/bundle.py +39 -10
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/cli.py +3 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.i18n.js +4 -4
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.js +19 -10
- hypgraph-0.7.18/tests/test_verify_scoped_views.py +198 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/.gitignore +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/AGENTS.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/CITATION.cff +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/DISCLAIMER.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/LICENSE +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/NOTICE +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/README.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/site-plan.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/examples/frameworks/README.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/pyproject.toml +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/blind.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/graph.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/references.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/schema.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/server.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_anchoring.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_blind.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_blind_oracles.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_coord.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_d10.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_engine.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_framework_examples.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_i18n.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_references.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_trust_boundary.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_complete.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_lifecycle.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_scope.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_trust.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_views.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.18 — 2026-10-31: a scoped bundle can't call an active node superseded
|
|
4
|
+
|
|
5
|
+
- **A scoped bundle can't show an active node as superseded and still say its views are checked.** A re-audit of
|
|
6
|
+
0.7.17 found that a bundle naming a `scope.inv` (which the chain doesn't cover) could show any node, an active claim
|
|
7
|
+
or the investigation itself, as superseded, and verify against its published head with `views: "checked"` and
|
|
8
|
+
coverage complete: the verifier let a scoped bundle call any non-withdrawn node superseded, because links from
|
|
9
|
+
other investigations are left out. Now, in the Python and browser verifiers alike, that allowance applies only
|
|
10
|
+
when events really are redacted (without them every link is in the bundle, so the lifecycle fails) and only to a
|
|
11
|
+
type a `supersedes` or `corrects` link can point at (never an investigation or a review), and even then the views
|
|
12
|
+
are `"unconfirmed"`, not `"checked"`, with a warning naming each node; `hypgraph verify` prints
|
|
13
|
+
`views: NOT CHECKED`. The Explorer header shows a *views not checked* pill for `historical` and `unconfirmed`
|
|
14
|
+
views, and the verify page names both instead of calling them checked (`tests/test_verify_scoped_views.py`).
|
|
15
|
+
- **The spec says what a verifier does with lifecycles.** `docs/spec.md` and `docs/verification.md` no longer say a
|
|
16
|
+
verifier accepts the value either rule gives: the current rule is authoritative, a 0.3-only lifecycle is
|
|
17
|
+
`historical`, a scoped one superseded from outside is `unconfirmed`, anything else fails.
|
|
18
|
+
|
|
3
19
|
## 0.7.17 — 2026-10-31: a superseded node can't verify as active
|
|
4
20
|
|
|
5
21
|
- **A superseded node can't verify as active.** A pre-launch audit of 0.7.16 found that the verifier accepted a node's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.18
|
|
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/
|
|
@@ -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.18 (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
|
|
@@ -58,6 +58,18 @@ was taken from a changelog without being checked against the code.*
|
|
|
58
58
|
> **Where that stands.** #75 is merged with green CI (17 of 17 checks); from the 0.7.17 wheel in a fresh venv the
|
|
59
59
|
> suite is 424 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Released as 0.7.17.
|
|
60
60
|
|
|
61
|
+
> **Re-audit of 0.7.17 (2026-10-31).** Confirmed and fixed for 0.7.18: a bundle naming a `scope.inv` (not covered by
|
|
62
|
+
> the chain) could show any non-withdrawn node, an active claim or the investigation itself, as superseded and verify
|
|
63
|
+
> against its published head with `views: "checked"` and coverage complete; the published Broad Street bundle, which
|
|
64
|
+
> has no redacted events, was enough. The scoped allowance now needs redacted events and a type a `supersedes` /
|
|
65
|
+
> `corrects` link can reach (never an investigation or a review), and even then the views are `"unconfirmed"`, with
|
|
66
|
+
> each node named, in Python and the browser alike. The verify page and the Explorer header now say *views not
|
|
67
|
+
> checked* for `historical` and `unconfirmed` views, and the spec no longer says a verifier accepts either rule.
|
|
68
|
+
> Tests: `tests/test_verify_scoped_views.py`.
|
|
69
|
+
>
|
|
70
|
+
> **Where that stands.** #77 is merged with green CI (17 of 17 checks); from the 0.7.18 wheel in a fresh venv the
|
|
71
|
+
> suite is 431 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Released as 0.7.18.
|
|
72
|
+
|
|
61
73
|
## Recommendation: CONDITIONAL GO
|
|
62
74
|
|
|
63
75
|
HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
|
|
@@ -153,7 +153,9 @@ out a change hidden among them. `scope.title` is a label; a viewer names the sco
|
|
|
153
153
|
**What a verifier vouches for.** `ok` means the chain links and every row and event agree, both ways. `coverage:
|
|
154
154
|
complete` means every row field was vouched for by its event and nothing is redacted (`partial` otherwise). `views:
|
|
155
155
|
checked` means every fact the derived views show that the rows fix is present and agrees with the rows, and the
|
|
156
|
-
scope is a real one; `views: none` means the bundle carries no views.
|
|
156
|
+
scope is a real one; `views: none` means the bundle carries no views. `views: historical` and `views: unconfirmed`
|
|
157
|
+
mean the views verify but some lifecycles in them couldn't be checked (below), and the warning names each one: a
|
|
158
|
+
viewer must not present them as checked. None of them vouches for standing, the report,
|
|
157
159
|
stages or work orders (as exported), for who an author really is, or that the head was published.
|
|
158
160
|
|
|
159
161
|
**Anchoring.** A bundle proves only its own consistency. To prove it wasn't rebuilt from scratch, publish its `head`
|
|
@@ -171,10 +173,16 @@ investigation node), which investigations the list shows (each once), which node
|
|
|
171
173
|
`by` and `why`. Every one of these must be **present**, not only equal where present: a viewer fills a missing fact
|
|
172
174
|
with a default, and a withdrawn claim without its lifecycle would read as active. The lifecycle is derived from the
|
|
173
175
|
verified rows (withdrawn when a withdrawal exists; superseded when a live `supersedes` or `corrects` replacement is
|
|
174
|
-
active or, since 0.4, itself superseded; an investigation's own `state` otherwise)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
active or, since 0.4, itself superseded; an investigation's own `state` otherwise). A verifier checks the
|
|
177
|
+
lifecycle against the current rule. A lifecycle that only 0.3's rule gives (it counted an active replacement only)
|
|
178
|
+
verifies, since a 0.3 export shows it so, but the chain doesn't record which version exported a bundle, so the views
|
|
179
|
+
are `historical`, not `checked`. A scoped bundle with redacted events leaves out links from other investigations, and
|
|
180
|
+
one of them may replace a node: a node shown superseded with no replacement in the rows verifies only if its type can
|
|
181
|
+
be replaced at all (every type but `investigation` and `review`) and it isn't withdrawn, and the views are
|
|
182
|
+
`unconfirmed`, since that replacement is among the redacted events. A bundle with no redacted events holds every link,
|
|
183
|
+
scoped or not, so any other lifecycle the rows don't give fails. `scope` isn't covered by the chain, so naming one
|
|
184
|
+
never loosens a check on its own. A bundle exported without derived views verifies, and a verifier says it has none
|
|
185
|
+
(`views: none`). **Standing** (review state, support, at-risk, the sentence) can't
|
|
178
186
|
be checked from the rows alone: it is what the exporting engine derived, and so are the report, the workflow stages,
|
|
179
187
|
completion, the question board and work-order status. A verifier reports them as not checked, or re-derives them with
|
|
180
188
|
the same engine version (`hypgraph verify FILE --standing`, under the caller's policy with `--policy`) and compares.
|
|
@@ -90,6 +90,11 @@ separate implementation.
|
|
|
90
90
|
title and lifecycle, each link's ends and fields, the full investigation list — must be present in the derived
|
|
91
91
|
views, not only right where present: a view that drops a withdrawn claim's lifecycle fails, rather than being shown
|
|
92
92
|
as active. A bundle exported without derived views (`export(derived=False)`) verifies and says `views: none`.
|
|
93
|
+
- **A lifecycle that can't be checked isn't called checked.** One only hypgraph 0.3's rule gives verifies with
|
|
94
|
+
`views: historical`. In a scoped bundle with redacted events, a node shown superseded with no replacement in the
|
|
95
|
+
rows (a replacement in another investigation would be redacted) verifies with `views: unconfirmed`, and only if its
|
|
96
|
+
type can be superseded at all; investigations and reviews can't. Without redacted events every link is in the
|
|
97
|
+
bundle, so such a node fails. The warning names each node; the Explorer and the verify page say *views not checked*.
|
|
93
98
|
|
|
94
99
|
## Scoped bundles and references
|
|
95
100
|
|
|
@@ -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.18"
|
|
20
20
|
|
|
21
21
|
from .bundle import anchored, verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -34,7 +34,7 @@ from pathlib import Path
|
|
|
34
34
|
from typing import TYPE_CHECKING, Any
|
|
35
35
|
|
|
36
36
|
from . import __version__
|
|
37
|
-
from .schema import SAME_TYPE
|
|
37
|
+
from .schema import RELS, SAME_TYPE
|
|
38
38
|
from .store import GENESIS, ROW_TABLES, check_rows, event_body, now
|
|
39
39
|
|
|
40
40
|
if TYPE_CHECKING:
|
|
@@ -242,13 +242,15 @@ def _verify_bundle(b: dict, policy: dict | None = None) -> dict:
|
|
|
242
242
|
if problem:
|
|
243
243
|
return {"ok": False, "problem": problem}
|
|
244
244
|
historical: list = []
|
|
245
|
-
|
|
245
|
+
unconfirmed: list = []
|
|
246
|
+
problem = _derived_problem(b, tables, historical, unconfirmed, red)
|
|
246
247
|
if problem:
|
|
247
248
|
return {"ok": False, "problem": f"the derived views contradict the verified record: {problem}"}
|
|
248
249
|
out = {"ok": True, "checked": checked, "head": prev, "complete": red == 0, "redacted": red,
|
|
249
250
|
"nodes": len(tables["nodes"]), "edges": len(tables["edges"]), "unchecked": rows["unchecked"],
|
|
250
251
|
"coverage": "complete" if not rows["unchecked"] and not red else "partial",
|
|
251
|
-
"views": "none" if b.get("derived") is None else "historical" if historical
|
|
252
|
+
"views": ("none" if b.get("derived") is None else "historical" if historical
|
|
253
|
+
else "unconfirmed" if unconfirmed else "checked"),
|
|
252
254
|
"policy": {"trusted": sorted(policy or {}), "importer": importer},
|
|
253
255
|
"standing": {"checked": False, "note": STANDING_NOTE}}
|
|
254
256
|
notes = []
|
|
@@ -262,19 +264,31 @@ def _verify_bundle(b: dict, policy: dict | None = None) -> dict:
|
|
|
262
264
|
"used.")
|
|
263
265
|
if historical:
|
|
264
266
|
notes.append(_historical_note(historical))
|
|
267
|
+
if unconfirmed:
|
|
268
|
+
notes.append(_unconfirmed_note(unconfirmed))
|
|
265
269
|
if notes:
|
|
266
270
|
out["warning"] = " ".join(notes)
|
|
267
271
|
return out
|
|
268
272
|
|
|
269
273
|
|
|
274
|
+
def _listed(names: list) -> str:
|
|
275
|
+
return "; ".join(names[:8]) + (f"; and {len(names) - 8} more" if len(names) > 8 else "")
|
|
276
|
+
|
|
277
|
+
|
|
270
278
|
def _historical_note(historical: list) -> str:
|
|
271
|
-
shown =
|
|
272
|
-
more = f"; and {len(historical) - 8} more" if len(historical) > 8 else ""
|
|
279
|
+
shown = _listed([f"{name} shown {s}, the current rule makes it {w}" for name, s, w in historical])
|
|
273
280
|
return (f"The views are not checked: {len(historical)} lifecycle(s) follow hypgraph 0.3's rule, not the current "
|
|
274
|
-
f"one ({shown}
|
|
281
|
+
f"one ({shown}). A bundle exported by 0.3 shows them so, but the chain doesn't record which version "
|
|
275
282
|
"exported a bundle, so this can't be told from an edited view. Go by the current rule.")
|
|
276
283
|
|
|
277
284
|
|
|
285
|
+
def _unconfirmed_note(unconfirmed: list) -> str:
|
|
286
|
+
return (f"The views are not checked: {len(unconfirmed)} node(s) are shown superseded with no replacement in this "
|
|
287
|
+
f"bundle ({_listed(unconfirmed)}). A replacement in another investigation would be among the redacted "
|
|
288
|
+
"events, which keep only their hashes, so this can't be confirmed here: the rows make them active. Only "
|
|
289
|
+
"the full record, verified against the published head, settles it.")
|
|
290
|
+
|
|
291
|
+
|
|
278
292
|
def _j(v: Any) -> str:
|
|
279
293
|
"""A value in a problem, written as the browser's verifier writes it (JSON.stringify)."""
|
|
280
294
|
return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -320,6 +334,10 @@ def _scope_problem(b: dict, tables: dict, redacted: int) -> str:
|
|
|
320
334
|
return ""
|
|
321
335
|
|
|
322
336
|
|
|
337
|
+
# the node types a supersedes or corrects link can point at: no other type can be superseded
|
|
338
|
+
REPLACEABLE = frozenset().union(*(RELS[r][1] for r in SAME_TYPE))
|
|
339
|
+
|
|
340
|
+
|
|
323
341
|
def _eid(v: Any) -> int | None:
|
|
324
342
|
"""A link id as the browser's verifier reads one (Number.isInteger): 7 and 7.0, not true or "7"."""
|
|
325
343
|
if isinstance(v, bool) or not isinstance(v, (int, float)) or not float(v).is_integer():
|
|
@@ -327,7 +345,8 @@ def _eid(v: Any) -> int | None:
|
|
|
327
345
|
return int(v)
|
|
328
346
|
|
|
329
347
|
|
|
330
|
-
def _derived_problem(b: dict, tables: dict, historical: list | None = None
|
|
348
|
+
def _derived_problem(b: dict, tables: dict, historical: list | None = None, unconfirmed: list | None = None,
|
|
349
|
+
redacted: int = 0) -> str:
|
|
331
350
|
"""The derived views are presentation, but a viewer shows them beside a verified chain: every fact in them that
|
|
332
351
|
the rows fix — which investigations there are, which nodes and live links each has, each node's type, code,
|
|
333
352
|
author, time, version, title and lifecycle, each link's ends and fields — must be present and agree with the
|
|
@@ -337,7 +356,13 @@ def _derived_problem(b: dict, tables: dict, historical: list | None = None) -> s
|
|
|
337
356
|
A lifecycle is checked against the current rule only. One that matches only 0.3's rule (the end of a chain of
|
|
338
357
|
replacements shown active) is no problem — a 0.3 export shows it so — but it isn't checked either: the chain
|
|
339
358
|
doesn't record which version exported a bundle, so it can't be told from an edited view. Each such node goes on
|
|
340
|
-
``historical`` as (name, shown, current), and the bundle's views are reported as historical, not checked.
|
|
359
|
+
``historical`` as (name, shown, current), and the bundle's views are reported as historical, not checked.
|
|
360
|
+
|
|
361
|
+
A bundle with no redacted events holds every link, scoped or not, so a lifecycle the rows don't give fails. A
|
|
362
|
+
scoped bundle with redacted events leaves out links from other investigations, and one of them may replace a node:
|
|
363
|
+
a node of a type that can be replaced (``REPLACEABLE``) shown superseded with no replacement in the rows goes on
|
|
364
|
+
``unconfirmed`` by name, and the views are reported as unconfirmed, not checked. ``scope`` isn't covered by the
|
|
365
|
+
chain, so naming one never loosens anything on its own."""
|
|
341
366
|
d = b.get("derived")
|
|
342
367
|
if d is None:
|
|
343
368
|
return ""
|
|
@@ -392,8 +417,12 @@ def _derived_problem(b: dict, tables: dict, historical: list | None = None) -> s
|
|
|
392
417
|
w, shown = lifecycle(n["id"], True), st["lifecycle"]
|
|
393
418
|
if shown == w:
|
|
394
419
|
return ""
|
|
395
|
-
if scope and n["id"] not in withdrawn
|
|
396
|
-
|
|
420
|
+
if (shown == "superseded" and scope and redacted and n["id"] not in withdrawn
|
|
421
|
+
and n["type"] in REPLACEABLE):
|
|
422
|
+
# a replacement in another investigation is among the redacted events: possible, not confirmable
|
|
423
|
+
if unconfirmed is not None and name not in unconfirmed:
|
|
424
|
+
unconfirmed.append(name)
|
|
425
|
+
return ""
|
|
397
426
|
if shown == lifecycle(n["id"], False):
|
|
398
427
|
if historical is not None and (name, shown, w) not in historical:
|
|
399
428
|
historical.append((name, shown, w))
|
|
@@ -299,6 +299,9 @@ def cmd_verify(args) -> int:
|
|
|
299
299
|
text += "\n no derived views: exported without them, so a viewer has nothing to show beyond the rows"
|
|
300
300
|
if ok and res.get("views") == "historical":
|
|
301
301
|
text += "\n views: NOT CHECKED — some lifecycles follow hypgraph 0.3's rule, not the current one (see the note)"
|
|
302
|
+
if ok and res.get("views") == "unconfirmed":
|
|
303
|
+
text += ("\n views: NOT CHECKED — some nodes are shown superseded by something outside this bundle, which "
|
|
304
|
+
"can't be confirmed here (see the note)")
|
|
302
305
|
if ok and res.get("unchecked"):
|
|
303
306
|
text += ("\n not covered (written before events hashed these fields): "
|
|
304
307
|
+ ", ".join(f"{f} ×{n}" for f, n in sorted(res["unchecked"].items())))
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"chain.ok": "⛓ verificado · {n}", "chain.ok.tip": "cadena de hashes y cada fila verificadas de nuevo: {n} eventos, cabeza {head}…",
|
|
22
22
|
"chain.partial": "cobertura parcial", "chain.partial.tip": "No todos los campos de las filas están respaldados por su evento",
|
|
23
23
|
"chain.partial.redacted": "{n} evento(s) fuera de esta investigación están ocultos", "chain.exported": "situación tal como se exportó",
|
|
24
|
-
"chain.bad": "⛓ no se verifica", "chain.bad.at": "⛓ no se verifica en el #{n}",
|
|
24
|
+
"chain.views": "vistas no comprobadas", "chain.bad": "⛓ no se verifica", "chain.bad.at": "⛓ no se verifica en el #{n}",
|
|
25
25
|
"stream.live": "en directo", "stream.polling": "consultando", "stream.polling.tip": "este servidor no tiene flujo de eventos; se consulta cada 5 s",
|
|
26
26
|
"stream.reconnecting": "reconectando", "stream.offline": "sin conexión",
|
|
27
27
|
"exported": "{what} según el bundle exportado, sin verificar: la cadena cubre las filas del registro, no lo que un motor derivó de ellas. Vuelva a derivarlo con <code>hypgraph verify --standing</code>.",
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
"chain.ok": "⛓ vérifié · {n}", "chain.ok.tip": "chaîne de hachage et chaque ligne vérifiées à nouveau : {n} événements, tête {head}…",
|
|
155
155
|
"chain.partial": "couverture partielle", "chain.partial.tip": "Tous les champs des lignes ne sont pas garantis par leur événement",
|
|
156
156
|
"chain.partial.redacted": "{n} événement(s) hors de cette enquête sont masqués", "chain.exported": "statut tel qu'exporté",
|
|
157
|
-
"chain.bad": "⛓ ne se vérifie pas", "chain.bad.at": "⛓ ne se vérifie pas au n° {n}",
|
|
157
|
+
"chain.views": "vues non contrôlées", "chain.bad": "⛓ ne se vérifie pas", "chain.bad.at": "⛓ ne se vérifie pas au n° {n}",
|
|
158
158
|
"stream.live": "en direct", "stream.polling": "interrogation", "stream.polling.tip": "cet hôte n'a pas de flux d'événements ; interrogation toutes les 5 s",
|
|
159
159
|
"stream.reconnecting": "reconnexion", "stream.offline": "hors ligne",
|
|
160
160
|
"exported": "{what} d'après le bundle exporté, sans vérification : la chaîne couvre les lignes du registre, pas ce qu'un moteur en a dérivé. Dérivez-le à nouveau avec <code>hypgraph verify --standing</code>.",
|
|
@@ -287,7 +287,7 @@
|
|
|
287
287
|
"chain.ok": "⛓ verifiziert · {n}", "chain.ok.tip": "Hash-Kette und jede Zeile erneut verifiziert: {n} Ereignisse, Head {head}…",
|
|
288
288
|
"chain.partial": "teilweise Abdeckung", "chain.partial.tip": "Nicht jedes Zeilenfeld ist durch sein Ereignis abgesichert",
|
|
289
289
|
"chain.partial.redacted": "{n} Ereignis(se) außerhalb dieser Untersuchung sind geschwärzt", "chain.exported": "Stand wie exportiert",
|
|
290
|
-
"chain.bad": "⛓ nicht verifizierbar", "chain.bad.at": "⛓ nicht verifizierbar bei Nr. {n}",
|
|
290
|
+
"chain.views": "Ansichten nicht geprüft", "chain.bad": "⛓ nicht verifizierbar", "chain.bad.at": "⛓ nicht verifizierbar bei Nr. {n}",
|
|
291
291
|
"stream.live": "live", "stream.polling": "Abfrage", "stream.polling.tip": "dieser Host hat keinen Ereignisstrom; Abfrage alle 5 s",
|
|
292
292
|
"stream.reconnecting": "verbindet neu", "stream.offline": "offline",
|
|
293
293
|
"exported": "{what} unverifiziert aus dem exportierten Bundle: Die Kette deckt die Zeilen des Protokolls ab, nicht das, was eine Engine daraus abgeleitet hat. Leiten Sie es mit <code>hypgraph verify --standing</code> neu ab.",
|
|
@@ -420,7 +420,7 @@
|
|
|
420
420
|
"chain.ok": "⛓ 已验证 · {n}", "chain.ok.tip": "哈希链和每一行均已重新验证:{n} 个事件,链头 {head}…",
|
|
421
421
|
"chain.partial": "部分覆盖", "chain.partial.tip": "并非每个行字段都有其事件担保",
|
|
422
422
|
"chain.partial.redacted": "此调查之外的 {n} 个事件已隐去", "chain.exported": "现状为导出时所带",
|
|
423
|
-
"chain.bad": "⛓ 验证失败", "chain.bad.at": "⛓ 在第 {n} 号验证失败",
|
|
423
|
+
"chain.views": "视图未核查", "chain.bad": "⛓ 验证失败", "chain.bad.at": "⛓ 在第 {n} 号验证失败",
|
|
424
424
|
"stream.live": "实时", "stream.polling": "轮询中", "stream.polling.tip": "此主机没有事件流;每 5 秒检查一次",
|
|
425
425
|
"stream.reconnecting": "正在重连", "stream.offline": "离线",
|
|
426
426
|
"exported": "{what}为记录包导出时所带,未经验证:哈希链只覆盖记录的行,不覆盖引擎从中推导出的内容。可用 <code>hypgraph verify --standing</code> 重新推导。",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
33
33
|
const RISKY = ['requires', 'derived_from', 'based_on'];
|
|
34
34
|
const SAME_TYPE = ['supersedes', 'corrects']; // schema.SAME_TYPE: a replacement of the same type
|
|
35
|
+
// bundle.REPLACEABLE: the types a supersedes / corrects link can point at (schema.RELS); no other type is superseded
|
|
36
|
+
const REPLACEABLE = ['artifact', 'claim', 'decision', 'experiment', 'hypothesis', 'question', 'result', 'run', 'source', 'task'];
|
|
35
37
|
const cap = (t) => (t ? t[0].toUpperCase() + t.slice(1) : '');
|
|
36
38
|
|
|
37
39
|
// ── hashing (WebCrypto, with a small fallback for insecure contexts) ─────
|
|
@@ -204,8 +206,9 @@
|
|
|
204
206
|
|
|
205
207
|
/** The derived views are presentation; every fact in them the rows fix must agree with the verified rows. A
|
|
206
208
|
* lifecycle is checked against the current rule only; one that matches only 0.3's rule goes on `historical` as
|
|
207
|
-
* [name, shown, current] (bundle.py _derived_problem): not a problem, not checked either.
|
|
208
|
-
|
|
209
|
+
* [name, shown, current] (bundle.py _derived_problem): not a problem, not checked either. A scoped bundle with
|
|
210
|
+
* redacted events may show a replaceable node superseded from another investigation: it goes on `unconfirmed`. */
|
|
211
|
+
function derivedProblem(b, tables, historical = null, unconfirmed = null, redacted = 0) {
|
|
209
212
|
const d = b.derived;
|
|
210
213
|
if (d == null) return '';
|
|
211
214
|
if (typeof d !== 'object') return 'derived is not an object';
|
|
@@ -241,7 +244,11 @@
|
|
|
241
244
|
if (!st || typeof st !== 'object' || !('lifecycle' in st)) return `${where} shows ${name} without its lifecycle`;
|
|
242
245
|
const w = lifecycle(n.id, true), shown = st.lifecycle;
|
|
243
246
|
if (shown === w) return '';
|
|
244
|
-
if (scope && !withdrawn.has(n.id) &&
|
|
247
|
+
if (shown === 'superseded' && scope && redacted && !withdrawn.has(n.id) && REPLACEABLE.includes(n.type)) {
|
|
248
|
+
// a replacement in another investigation is among the redacted events: possible, not confirmable
|
|
249
|
+
if (unconfirmed && !unconfirmed.includes(name)) unconfirmed.push(name);
|
|
250
|
+
return '';
|
|
251
|
+
}
|
|
245
252
|
if (shown === lifecycle(n.id, false)) {
|
|
246
253
|
if (historical && !historical.some(([a, s, c]) => a === name && s === shown && c === w)) historical.push([name, shown, w]);
|
|
247
254
|
return '';
|
|
@@ -342,22 +349,23 @@
|
|
|
342
349
|
if (rows.problem) return { ok: false, problem: rows.problem + (stated !== importer ? ` (the bundle names ${stated} as its importer, which the chain doesn't cover; if you trust that, verify with --importer ${stated})` : '') };
|
|
343
350
|
const sp = scopeProblem(b, tables, red);
|
|
344
351
|
if (sp) return { ok: false, problem: sp };
|
|
345
|
-
const historical = [];
|
|
346
|
-
const dp = derivedProblem(b, tables, historical);
|
|
352
|
+
const historical = [], unconfirmed = [];
|
|
353
|
+
const dp = derivedProblem(b, tables, historical, unconfirmed, red);
|
|
347
354
|
if (dp) return { ok: false, problem: `the derived views contradict the verified record: ${dp}` };
|
|
348
355
|
const partial = Object.keys(rows.unchecked).length > 0 || red > 0;
|
|
349
356
|
const out = { ok: true, checked, head: prev, complete: red === 0, redacted: red, nodes: tables.nodes.length,
|
|
350
357
|
edges: tables.edges.length, unchecked: rows.unchecked, coverage: partial ? 'partial' : 'complete',
|
|
351
|
-
views: b.derived == null ? 'none' : historical.length ? 'historical' : 'checked',
|
|
358
|
+
views: b.derived == null ? 'none' : historical.length ? 'historical' : unconfirmed.length ? 'unconfirmed' : 'checked',
|
|
352
359
|
policy: { trusted: Object.keys(policy || {}).sort(), importer }, standing: { checked: false, note: STANDING_NOTE } };
|
|
353
360
|
const notes = [];
|
|
354
361
|
if (red) notes.push(`${red} event(s) are redacted. The bundle says they lie outside this investigation, but a redacted event keeps only its hash, so that can't be checked: a change hidden among them (a withdrawal, say) would not show. Only the full record, verified against the published head, rules that out.`);
|
|
355
362
|
if (stated !== importer) notes.push(`The bundle names ${stated} as its importer; the chain doesn't cover that, so ${importer} was used.`);
|
|
363
|
+
const listed = (names) => names.slice(0, 8).join('; ') + (names.length > 8 ? `; and ${names.length - 8} more` : '');
|
|
356
364
|
if (historical.length) {
|
|
357
|
-
const shown = historical.
|
|
358
|
-
|
|
359
|
-
notes.push(`The views are not checked: ${historical.length} lifecycle(s) follow hypgraph 0.3's rule, not the current one (${shown}${more}). A bundle exported by 0.3 shows them so, but the chain doesn't record which version exported a bundle, so this can't be told from an edited view. Go by the current rule.`);
|
|
365
|
+
const shown = listed(historical.map(([a, s, c]) => `${a} shown ${s}, the current rule makes it ${c}`));
|
|
366
|
+
notes.push(`The views are not checked: ${historical.length} lifecycle(s) follow hypgraph 0.3's rule, not the current one (${shown}). A bundle exported by 0.3 shows them so, but the chain doesn't record which version exported a bundle, so this can't be told from an edited view. Go by the current rule.`);
|
|
360
367
|
}
|
|
368
|
+
if (unconfirmed.length) notes.push(`The views are not checked: ${unconfirmed.length} node(s) are shown superseded with no replacement in this bundle (${listed(unconfirmed)}). A replacement in another investigation would be among the redacted events, which keep only their hashes, so this can't be confirmed here: the rows make them active. Only the full record, verified against the published head, settles it.`);
|
|
361
369
|
if (notes.length) out.warning = notes.join(' ');
|
|
362
370
|
return anchored(out, head);
|
|
363
371
|
}
|
|
@@ -599,7 +607,7 @@
|
|
|
599
607
|
"chain.ok": "⛓ verified · {n}", "chain.ok.tip": "hash chain and every row re-verified: {n} events, head {head}…",
|
|
600
608
|
"chain.partial": "partial coverage", "chain.partial.tip": "Not every row field is vouched for by its event",
|
|
601
609
|
"chain.partial.redacted": "{n} event(s) outside this investigation are redacted", "chain.exported": "standing as exported",
|
|
602
|
-
"chain.bad": "⛓ does not verify", "chain.bad.at": "⛓ does not verify at #{n}",
|
|
610
|
+
"chain.views": "views not checked", "chain.bad": "⛓ does not verify", "chain.bad.at": "⛓ does not verify at #{n}",
|
|
603
611
|
"stream.live": "live", "stream.polling": "polling", "stream.polling.tip": "this host has no event stream; checking every 5 s",
|
|
604
612
|
"stream.reconnecting": "reconnecting", "stream.offline": "offline",
|
|
605
613
|
"exported": "{what} as exported with the bundle, not verified: the chain covers the record's rows, not what an engine derived from them. Re-derive it with <code>hypgraph verify --standing</code>.",
|
|
@@ -892,6 +900,7 @@
|
|
|
892
900
|
// the verifier's own problem, warning and note are shown verbatim (English): only the frame around them is translated
|
|
893
901
|
$('#hg-chain').innerHTML = !v ? '' : v.ok ? `<span class="pill ok" title="${esc(T('chain.ok.tip', { n: v.checked, head: String(v.head || '').slice(0, 12) }))}">${esc(T('chain.ok', { n: v.checked }))}</span>` +
|
|
894
902
|
(v.coverage === 'partial' ? ` <span class="pill warn" title="${esc(T('chain.partial.tip') + (Object.keys(v.unchecked || {}).length ? ': ' + Object.entries(v.unchecked || {}).map(([f, n]) => `${f} ×${n}`).join(', ') : '') + (v.redacted ? '; ' + T('chain.partial.redacted', { n: v.redacted }) : ''))}">${esc(T('chain.partial'))}</span>` : '') +
|
|
903
|
+
(v.views === 'historical' || v.views === 'unconfirmed' ? ` <span class="pill warn" title="${esc(v.warning || '')}">${esc(T('chain.views'))}</span>` : '') +
|
|
895
904
|
(v.standing && !v.standing.checked ? ` <span class="pill neutral" title="${esc(v.standing.note || '')}">${esc(T('chain.exported'))}</span>` : '')
|
|
896
905
|
: `<span class="pill bad" title="${esc(v.problem || '')}">${esc(v.broken_at ? T('chain.bad.at', { n: v.broken_at }) : T('chain.bad'))}</span>`;
|
|
897
906
|
const s = !live ? '' : S.stream === 'live' ? `<span class="pill ok"><span class="d"></span>${esc(T('stream.live'))}</span>`
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""A scoped bundle can't show an active node as superseded and still say its views are checked (re-audit of 0.7.17,
|
|
2
|
+
2026-10-31).
|
|
3
|
+
|
|
4
|
+
The verifier let a scoped bundle call any non-withdrawn node superseded, since a scoped export leaves out links from
|
|
5
|
+
other investigations. But ``scope.inv`` isn't covered by the chain: naming one on the published Broad Street bundle,
|
|
6
|
+
which has no redacted events, let an active claim, or the investigation itself, be shown superseded and verify
|
|
7
|
+
against the published head with ``views: "checked"`` and coverage complete. Now the allowance needs redacted events
|
|
8
|
+
(without them every link is in the bundle) and a node of a type a ``supersedes`` / ``corrects`` link can reach (never
|
|
9
|
+
an investigation or a review), and even then the views are ``unconfirmed``, with each node named. The browser's
|
|
10
|
+
verifier must decide every case the same way.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from hypgraph.bundle import REPLACEABLE, verify_bundle
|
|
25
|
+
from hypgraph.demos import broad_street
|
|
26
|
+
from hypgraph.schema import RELS, SAME_TYPE, TYPES
|
|
27
|
+
|
|
28
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
29
|
+
UI_JS = ROOT / "src" / "hypgraph" / "ui" / "hypgraph.js"
|
|
30
|
+
JS_VERIFY = ROOT / "dev" / "js_verify.js"
|
|
31
|
+
PUBLISHED = ROOT / "site" / "data" / "broad-street.bundle.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _nodes(m: dict, inv: str) -> list:
|
|
35
|
+
return m["derived"]["by_inv"][inv]["subgraph"]["nodes"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _shown(m: dict, inv: str, nid: str) -> dict:
|
|
39
|
+
return next(n for n in _nodes(m, inv) if n["id"] == nid)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _first(m: dict, inv: str, type: str, life: str = "active") -> dict:
|
|
43
|
+
return next(n for n in _nodes(m, inv) if n["type"] == type and n["state"]["lifecycle"] == life)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _inv_shown_superseded(m: dict, inv: str) -> None:
|
|
47
|
+
next(x for x in m["derived"]["investigations"] if x["id"] == inv)["state"]["lifecycle"] = "superseded"
|
|
48
|
+
_shown(m, inv, inv)["state"]["lifecycle"] = "superseded"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture(scope="module")
|
|
52
|
+
def pub():
|
|
53
|
+
b = json.loads(PUBLISHED.read_text(encoding="utf-8"))
|
|
54
|
+
inv = next(n["id"] for n in b["nodes"] if n["type"] == "investigation")
|
|
55
|
+
return {"bundle": b, "inv": inv}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture(scope="module")
|
|
59
|
+
def rec():
|
|
60
|
+
"""Broad Street with one result withdrawn, plus a second investigation whose hypothesis supersedes one of Broad
|
|
61
|
+
Street's: a scoped export of Broad Street shows that hypothesis superseded, and the link that does it is among the
|
|
62
|
+
redacted events."""
|
|
63
|
+
g, inv, _ = broad_street.build()
|
|
64
|
+
full = g.export()
|
|
65
|
+
old = _first(full, inv, "hypothesis")["id"]
|
|
66
|
+
res = next(n for n in full["nodes"] if n["inv"] == inv and n["type"] == "result")
|
|
67
|
+
g.withdraw(author=res["created_by"], node=res["id"], why="The tally was entered against the wrong street.")
|
|
68
|
+
farr = g.author("farr")
|
|
69
|
+
inv2 = farr.investigation("A second inquiry", "Whether the water theory holds in another district.")["id"]
|
|
70
|
+
farr.add("hypothesis", inv2, links=[{"rel": "supersedes", "to": old}],
|
|
71
|
+
statement="Cholera in the second district follows its water companies.",
|
|
72
|
+
predictions=["Deaths follow the water company, not the street."])
|
|
73
|
+
scoped = g.export(inv)
|
|
74
|
+
return {"g": g, "inv": inv, "old": old, "scoped": scoped, "full": g.export()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cases(pub, rec) -> dict:
|
|
78
|
+
"""name → (bundle or {bundle, head}, verdict): "checked", "unconfirmed" (verifies, views not checked) or False."""
|
|
79
|
+
b, inv = pub["bundle"], pub["inv"]
|
|
80
|
+
out = {}
|
|
81
|
+
|
|
82
|
+
def scoped_pub(name, verdict, edit=None):
|
|
83
|
+
m = copy.deepcopy(b)
|
|
84
|
+
m["scope"]["inv"] = inv
|
|
85
|
+
if edit:
|
|
86
|
+
edit(m)
|
|
87
|
+
out[name] = ({"bundle": m, "head": b["head"]}, verdict)
|
|
88
|
+
scoped_pub("published, scope named", "checked")
|
|
89
|
+
scoped_pub("published, scope named, active claim shown superseded", False,
|
|
90
|
+
lambda m: _first(m, inv, "claim").setdefault("state", {}).update(lifecycle="superseded"))
|
|
91
|
+
scoped_pub("published, scope named, investigation shown superseded", False,
|
|
92
|
+
lambda m: _inv_shown_superseded(m, inv))
|
|
93
|
+
|
|
94
|
+
s, sinv = rec["scoped"], rec["inv"]
|
|
95
|
+
|
|
96
|
+
def scoped_rec(name, verdict, edit=None):
|
|
97
|
+
m = copy.deepcopy(s)
|
|
98
|
+
if edit:
|
|
99
|
+
edit(m)
|
|
100
|
+
out[name] = (m, verdict)
|
|
101
|
+
scoped_rec("redacted, superseded from another investigation", "unconfirmed")
|
|
102
|
+
scoped_rec("redacted, an active claim shown superseded", "unconfirmed",
|
|
103
|
+
lambda m: _first(m, sinv, "claim").setdefault("state", {}).update(lifecycle="superseded"))
|
|
104
|
+
scoped_rec("redacted, investigation shown superseded", False, lambda m: _inv_shown_superseded(m, sinv))
|
|
105
|
+
scoped_rec("redacted, a review shown superseded", False,
|
|
106
|
+
lambda m: _first(m, sinv, "review")["state"].update(lifecycle="superseded"))
|
|
107
|
+
scoped_rec("redacted, withdrawn shown superseded", False,
|
|
108
|
+
lambda m: _first(m, sinv, "result", "withdrawn")["state"].update(lifecycle="superseded"))
|
|
109
|
+
scoped_rec("redacted, superseded hypothesis shown active", "checked",
|
|
110
|
+
lambda m: _shown(m, sinv, rec["old"])["state"].update(lifecycle="active"))
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _verify(x):
|
|
115
|
+
return verify_bundle(x["bundle"], head=x["head"]) if "bundle" in x else verify_bundle(x)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _verdict(v: dict):
|
|
119
|
+
return v["views"] if v["ok"] else False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_the_fixture_is_the_real_case(rec):
|
|
123
|
+
"""The scoped export redacts the second investigation's events and shows the old hypothesis superseded with no
|
|
124
|
+
replacement among its rows: the case the allowance exists for."""
|
|
125
|
+
s, old = rec["scoped"], rec["old"]
|
|
126
|
+
assert s["redacted_events"] > 0
|
|
127
|
+
assert _shown(s, rec["inv"], old)["state"]["lifecycle"] == "superseded"
|
|
128
|
+
assert not any(e["dst"] == old and e["rel"] in SAME_TYPE for e in s["edges"])
|
|
129
|
+
assert verify_bundle(rec["full"])["views"] == "checked"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_every_case_gets_its_verdict(pub, rec):
|
|
133
|
+
wrong = {}
|
|
134
|
+
for name, (x, want) in _cases(pub, rec).items():
|
|
135
|
+
v = _verify(x)
|
|
136
|
+
if _verdict(v) != want:
|
|
137
|
+
wrong[name] = (_verdict(v), v.get("problem") or v.get("warning"))
|
|
138
|
+
assert wrong == {}, wrong
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_the_audits_reproduction_fails(pub):
|
|
142
|
+
"""The published bundle with a scope named and an active claim shown superseded: chain, rows and head untouched."""
|
|
143
|
+
b, inv = copy.deepcopy(pub["bundle"]), pub["inv"]
|
|
144
|
+
b["scope"]["inv"] = inv
|
|
145
|
+
claim = _first(b, inv, "claim")
|
|
146
|
+
claim["state"]["lifecycle"] = "superseded"
|
|
147
|
+
v = verify_bundle(b, head=pub["bundle"]["head"])
|
|
148
|
+
assert not v["ok"], v
|
|
149
|
+
assert f'shows {claim["code"]} as "superseded"; the record makes it "active"' in v["problem"], v["problem"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_unconfirmed_names_each_node(rec):
|
|
153
|
+
v = verify_bundle(rec["scoped"], head=rec["scoped"]["head"])
|
|
154
|
+
assert v["ok"] and v["anchored"] and v["coverage"] == "partial" and v["views"] == "unconfirmed", v
|
|
155
|
+
code = _shown(rec["scoped"], rec["inv"], rec["old"])["code"]
|
|
156
|
+
assert re.search(rf"1 node\(s\) are shown superseded with no replacement in this bundle \({re.escape(code)}\)",
|
|
157
|
+
v["warning"]), v["warning"]
|
|
158
|
+
assert "The views are not checked" in v["warning"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_the_cli_says_the_views_are_not_checked(rec, tmp_path):
|
|
162
|
+
f = tmp_path / "unconfirmed.bundle.json"
|
|
163
|
+
f.write_text(json.dumps(rec["scoped"]), encoding="utf-8")
|
|
164
|
+
out = subprocess.run([sys.executable, "-m", "hypgraph", "verify", str(f)], capture_output=True, text=True,
|
|
165
|
+
encoding="utf-8")
|
|
166
|
+
assert out.returncode == 0 and "views: NOT CHECKED" in out.stdout and "outside this bundle" in out.stdout, out.stdout
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_replaceable_follows_the_schema():
|
|
170
|
+
"""Only a type a supersedes / corrects link can point at can be superseded; the browser's list is the same."""
|
|
171
|
+
want = set().union(*(RELS[r][1] for r in SAME_TYPE))
|
|
172
|
+
assert set(REPLACEABLE) == want and set(TYPES) - want == {"investigation", "review"}
|
|
173
|
+
js = UI_JS.read_text(encoding="utf-8")
|
|
174
|
+
listed = re.search(r"const REPLACEABLE = \[([^\]]*)\]", js).group(1)
|
|
175
|
+
assert set(re.findall(r"'([a-z_]+)'", listed)) == want
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.skipif(not shutil.which("node"), reason="node is not installed")
|
|
179
|
+
def test_the_browser_verifier_agrees(pub, rec, tmp_path):
|
|
180
|
+
cases = _cases(pub, rec)
|
|
181
|
+
paths = []
|
|
182
|
+
for i, (name, (x, _)) in enumerate(cases.items()):
|
|
183
|
+
p = tmp_path / f"s{i:03d}.json"
|
|
184
|
+
p.write_text(json.dumps(x), encoding="utf-8")
|
|
185
|
+
paths.append(p)
|
|
186
|
+
out = subprocess.run(["node", str(JS_VERIFY), str(UI_JS), *map(str, paths)], capture_output=True, text=True,
|
|
187
|
+
encoding="utf-8", timeout=300)
|
|
188
|
+
assert out.returncode == 0, out.stderr[-2000:]
|
|
189
|
+
js = json.loads(out.stdout)
|
|
190
|
+
disagree = {}
|
|
191
|
+
for (name, (x, want)), p in zip(cases.items(), paths):
|
|
192
|
+
py = _verify(x)
|
|
193
|
+
keys = ("ok", "views", "warning", "coverage", "anchored") if py["ok"] else ("ok", "problem")
|
|
194
|
+
py_said = {k: py.get(k) or "" for k in keys}
|
|
195
|
+
js_said = {k: js[p.name].get(k) or "" for k in keys}
|
|
196
|
+
if py_said != js_said or _verdict(py) != want:
|
|
197
|
+
disagree[name] = (py_said, js_said)
|
|
198
|
+
assert disagree == {}, disagree
|
|
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
|
|
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
|