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.
Files changed (118) hide show
  1. {hypgraph-0.7.17 → hypgraph-0.7.18}/CHANGELOG.md +16 -0
  2. {hypgraph-0.7.17 → hypgraph-0.7.18}/PKG-INFO +1 -1
  3. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/RELEASE-READINESS.md +12 -0
  5. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/spec.md +13 -5
  6. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/verification.md +5 -0
  7. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/__init__.py +1 -1
  8. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/bundle.py +39 -10
  9. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/cli.py +3 -0
  10. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.i18n.js +4 -4
  11. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.js +19 -10
  12. hypgraph-0.7.18/tests/test_verify_scoped_views.py +198 -0
  13. {hypgraph-0.7.17 → hypgraph-0.7.18}/.gitignore +0 -0
  14. {hypgraph-0.7.17 → hypgraph-0.7.18}/AGENTS.md +0 -0
  15. {hypgraph-0.7.17 → hypgraph-0.7.18}/CITATION.cff +0 -0
  16. {hypgraph-0.7.17 → hypgraph-0.7.18}/DISCLAIMER.md +0 -0
  17. {hypgraph-0.7.17 → hypgraph-0.7.18}/LICENSE +0 -0
  18. {hypgraph-0.7.17 → hypgraph-0.7.18}/NOTICE +0 -0
  19. {hypgraph-0.7.17 → hypgraph-0.7.18}/README.md +0 -0
  20. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  21. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  22. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/profile-workflows-plan.md +0 -0
  23. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/review-2026-10-24.md +0 -0
  24. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/site-plan.md +0 -0
  25. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/ui-plan.md +0 -0
  26. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/README.md +0 -0
  27. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/knowledge.md +0 -0
  28. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/optimization.md +0 -0
  29. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/scientific.md +0 -0
  30. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/software.md +0 -0
  31. {hypgraph-0.7.17 → hypgraph-0.7.18}/docs/workflows/trading.md +0 -0
  32. {hypgraph-0.7.17 → hypgraph-0.7.18}/examples/frameworks/README.md +0 -0
  33. {hypgraph-0.7.17 → hypgraph-0.7.18}/pyproject.toml +0 -0
  34. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/__main__.py +0 -0
  35. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/blind.py +0 -0
  36. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/checks.py +0 -0
  37. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/coord.py +0 -0
  38. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/demos/__init__.py +0 -0
  39. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/demos/broad_street.py +0 -0
  40. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/language.py +0 -0
  43. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/mcp.py +0 -0
  44. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/plan.py +0 -0
  45. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/policy.py +0 -0
  46. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/profiles.py +0 -0
  47. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/references.py +0 -0
  48. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/refusals.py +0 -0
  49. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/report_check.py +0 -0
  50. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/schema.py +0 -0
  51. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/server.py +0 -0
  52. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/skill/SKILL.md +0 -0
  53. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/stats.py +0 -0
  54. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/store.py +0 -0
  55. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/__init__.py +0 -0
  56. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/atlas.css +0 -0
  57. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/atlas.js +0 -0
  58. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.css +0 -0
  59. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/hypgraph.svg +0 -0
  60. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/index.html +0 -0
  61. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  62. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  63. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  64. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  65. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  66. {hypgraph-0.7.17 → hypgraph-0.7.18}/src/hypgraph/workflow.py +0 -0
  67. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_accept_work_kind.py +0 -0
  68. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_anchoring.py +0 -0
  69. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_blind.py +0 -0
  70. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_blind_oracles.py +0 -0
  71. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_bound_support.py +0 -0
  72. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_bundle_telemetry.py +0 -0
  73. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_cancel_releases_lease.py +0 -0
  74. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_cited_and_failed_support.py +0 -0
  75. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_coord.py +0 -0
  76. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_d10.py +0 -0
  77. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_engine.py +0 -0
  78. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_engine_fixes.py +0 -0
  79. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_framework_examples.py +0 -0
  80. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_i18n.py +0 -0
  81. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_incremental.py +0 -0
  82. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_link_translate.py +0 -0
  83. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_log_and_check.py +0 -0
  84. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_measure_questions.py +0 -0
  85. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_opposed_answers.py +0 -0
  86. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_option_lead.py +0 -0
  87. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_order_detail.py +0 -0
  88. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_profiles.py +0 -0
  89. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_profiles_041.py +0 -0
  90. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_questions_not_assertions.py +0 -0
  91. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_references.py +0 -0
  92. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_refusal_friction.py +0 -0
  93. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_refusal_results.py +0 -0
  94. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_refusals.py +0 -0
  95. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_relation_words.py +0 -0
  96. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_report_answers.py +0 -0
  97. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_rerunnable.py +0 -0
  98. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_restates_scale.py +0 -0
  99. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_server_auth.py +0 -0
  100. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_server_hosts.py +0 -0
  101. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_shape_refusals.py +0 -0
  102. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_stats_not_backtests.py +0 -0
  103. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_stats_values.py +0 -0
  104. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_surfaces.py +0 -0
  105. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_telemetry_stream.py +0 -0
  106. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_trust_boundary.py +0 -0
  107. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_ui_app.py +0 -0
  108. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_ui_engine.py +0 -0
  109. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_uncorrected_effect.py +0 -0
  110. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_unnamed_inputs.py +0 -0
  111. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_complete.py +0 -0
  112. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_lifecycle.py +0 -0
  113. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_scope.py +0 -0
  114. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_trust.py +0 -0
  115. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_verify_views.py +0 -0
  116. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_versions_not_figures.py +0 -0
  117. {hypgraph-0.7.17 → hypgraph-0.7.18}/tests/test_vocabulary_friction.py +0 -0
  118. {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.17
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.17 (2026-10-31)
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. None of them vouches for standing, the report,
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), and a verifier accepts the value
175
- either rule gives. A scoped bundle leaves out links to other investigations, so it may also call a node superseded
176
- (never a withdrawn node anything but withdrawn). A bundle exported without derived views verifies, and a verifier says
177
- it has none (`views: none`). **Standing** (review state, support, at-risk, the sentence) can't
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.17"
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
- problem = _derived_problem(b, tables, historical)
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 else "checked",
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 = "; ".join(f"{name} shown {s}, the current rule makes it {w}" for name, s, w in historical[:8])
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}{more}). A bundle exported by 0.3 shows them so, but the chain doesn't record which version "
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) -> str:
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 and shown == "superseded":
396
- return "" # a scoped bundle leaves out links to other investigations: one may replace it
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
- function derivedProblem(b, tables, historical = null) {
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) && shown === 'superseded') return ''; // a scoped bundle leaves out links to other investigations
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.slice(0, 8).map(([a, s, c]) => `${a} shown ${s}, the current rule makes it ${c}`).join('; ');
358
- const more = historical.length > 8 ? `; and ${historical.length - 8} more` : '';
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