hypgraph 0.8.1__tar.gz → 0.8.2__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 (133) hide show
  1. {hypgraph-0.8.1 → hypgraph-0.8.2}/CHANGELOG.md +21 -0
  2. {hypgraph-0.8.1 → hypgraph-0.8.2}/PKG-INFO +29 -1
  3. {hypgraph-0.8.1 → hypgraph-0.8.2}/README.md +28 -0
  4. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/PLAN.md +1 -1
  5. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/RELEASE-READINESS.md +7 -0
  6. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/__init__.py +1 -1
  7. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/checks.py +35 -11
  8. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/cli.py +17 -6
  9. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/demos/__init__.py +1 -1
  10. hypgraph-0.8.2/src/hypgraph/demos/dispute.py +168 -0
  11. hypgraph-0.8.2/tests/test_demo_dispute.py +52 -0
  12. hypgraph-0.8.2/tests/test_quoted_counterexample.py +107 -0
  13. {hypgraph-0.8.1 → hypgraph-0.8.2}/.gitignore +0 -0
  14. {hypgraph-0.8.1 → hypgraph-0.8.2}/AGENTS.md +0 -0
  15. {hypgraph-0.8.1 → hypgraph-0.8.2}/CITATION.cff +0 -0
  16. {hypgraph-0.8.1 → hypgraph-0.8.2}/DISCLAIMER.md +0 -0
  17. {hypgraph-0.8.1 → hypgraph-0.8.2}/LICENSE +0 -0
  18. {hypgraph-0.8.1 → hypgraph-0.8.2}/NOTICE +0 -0
  19. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  20. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  21. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/profile-workflows-plan.md +0 -0
  22. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/review-2026-10-24.md +0 -0
  23. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/site-plan.md +0 -0
  24. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/spec.md +0 -0
  25. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/ui-plan.md +0 -0
  26. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/verification.md +0 -0
  27. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/workflows/README.md +0 -0
  28. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/workflows/knowledge.md +0 -0
  29. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/workflows/optimization.md +0 -0
  30. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/workflows/scientific.md +0 -0
  31. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/workflows/software.md +0 -0
  32. {hypgraph-0.8.1 → hypgraph-0.8.2}/docs/workflows/trading.md +0 -0
  33. {hypgraph-0.8.1 → hypgraph-0.8.2}/examples/frameworks/README.md +0 -0
  34. {hypgraph-0.8.1 → hypgraph-0.8.2}/pyproject.toml +0 -0
  35. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/__main__.py +0 -0
  36. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/assertions.py +0 -0
  37. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/blind.py +0 -0
  38. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/bundle.py +0 -0
  39. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/coord.py +0 -0
  40. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/demos/broad_street.py +0 -0
  41. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/demos/linked.py +0 -0
  42. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/derive.py +0 -0
  43. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/graph.py +0 -0
  44. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/language.py +0 -0
  45. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/mcp.py +0 -0
  46. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/plan.py +0 -0
  47. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/policy.py +0 -0
  48. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/profiles.py +0 -0
  49. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/references.py +0 -0
  50. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/refusals.py +0 -0
  51. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/report_check.py +0 -0
  52. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/schema.py +0 -0
  53. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/server.py +0 -0
  54. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/skill/SKILL.md +0 -0
  55. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/stats.py +0 -0
  56. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/store.py +0 -0
  57. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/__init__.py +0 -0
  58. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/atlas.css +0 -0
  59. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/atlas.js +0 -0
  60. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.css +0 -0
  61. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  62. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.js +0 -0
  63. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.svg +0 -0
  64. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/index.html +0 -0
  65. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  66. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  67. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  68. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  69. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  70. {hypgraph-0.8.1 → hypgraph-0.8.2}/src/hypgraph/workflow.py +0 -0
  71. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_accept_work_kind.py +0 -0
  72. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_anchoring.py +0 -0
  73. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_assertions.py +0 -0
  74. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_blind.py +0 -0
  75. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_blind_oracles.py +0 -0
  76. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_borrowed_words.py +0 -0
  77. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_bound_support.py +0 -0
  78. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_bundle_telemetry.py +0 -0
  79. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_cancel_releases_lease.py +0 -0
  80. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_cited_and_failed_support.py +0 -0
  81. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_contradictions.py +0 -0
  82. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_coord.py +0 -0
  83. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_counts_that_bear.py +0 -0
  84. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_d10.py +0 -0
  85. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_direction_and_units.py +0 -0
  86. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_engine.py +0 -0
  87. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_engine_fixes.py +0 -0
  88. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_figure_said_twice.py +0 -0
  89. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_framework_examples.py +0 -0
  90. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_i18n.py +0 -0
  91. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_incremental.py +0 -0
  92. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_link_translate.py +0 -0
  93. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_linked_records.py +0 -0
  94. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_log_and_check.py +0 -0
  95. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_measure_questions.py +0 -0
  96. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_opposed_answers.py +0 -0
  97. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_option_lead.py +0 -0
  98. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_order_detail.py +0 -0
  99. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_profiles.py +0 -0
  100. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_profiles_041.py +0 -0
  101. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_quantifiers_and_identity.py +0 -0
  102. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_questions_not_assertions.py +0 -0
  103. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_references.py +0 -0
  104. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_refusal_friction.py +0 -0
  105. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_refusal_results.py +0 -0
  106. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_refusals.py +0 -0
  107. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_relation_words.py +0 -0
  108. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_report_answers.py +0 -0
  109. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_rerunnable.py +0 -0
  110. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_restates_scale.py +0 -0
  111. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_server_auth.py +0 -0
  112. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_server_hosts.py +0 -0
  113. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_shape_refusals.py +0 -0
  114. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_stats_not_backtests.py +0 -0
  115. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_stats_values.py +0 -0
  116. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_surfaces.py +0 -0
  117. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_telemetry_stream.py +0 -0
  118. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_trust_boundary.py +0 -0
  119. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_typed_audits.py +0 -0
  120. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_ui_app.py +0 -0
  121. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_ui_engine.py +0 -0
  122. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_uncorrected_effect.py +0 -0
  123. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_unnamed_inputs.py +0 -0
  124. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_verify_complete.py +0 -0
  125. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_verify_lifecycle.py +0 -0
  126. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_verify_scope.py +0 -0
  127. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_verify_scoped_views.py +0 -0
  128. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_verify_trust.py +0 -0
  129. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_verify_views.py +0 -0
  130. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_versions_not_figures.py +0 -0
  131. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_vocabulary_friction.py +0 -0
  132. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_what_a_count_is_of.py +0 -0
  133. {hypgraph-0.8.1 → hypgraph-0.8.2}/tests/test_workflows.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 — 2026-11-01: an extreme that quotes its own counterexample; a disputed answer
4
+
5
+ - **A quoted counterexample no longer excuses an extreme.** A stated count used to exempt an extreme whenever its
6
+ clause said "k of n ... than", meant for a count about a figure of the statement's own ("the shortest is 21 km; 1 of
7
+ 77 are shorter than 26 km"). It also exempted the count at the extreme's own figure, so "the shortest route is 30 km,
8
+ since 1 of 77 are shorter than 30 km" was accepted. The count is now an exception to the extreme whenever its figure,
9
+ on the result or in the sentence, is the extreme's own.
10
+ - **Two readings that exemption had hidden.** "The highest ... is therefore not 125" is a denial (an adverb between
11
+ the copula and "not"), and a bound in the extreme's own direction ("the shortest is at most 30 km", "91 or more") is
12
+ not a claim that the figure is the extreme. A bound the other way ("the shortest is at least 30 km") still claims it.
13
+ - Replayed on 1,900 recorded claim writes: 5 change, all now refused. Four are an extreme stated beside the count
14
+ that shows it false. One is a retraction worded as the claim ("the highest is 80, but this is not correct: 3 of
15
+ 1,024 are worth more"). Who the record names as still saying something false is unchanged or better on every set.
16
+ - **`hypgraph demo dispute`: a disputed answer.** A teaching record in which two parties answer one question from the
17
+ same road register. Ada makes an honest mistake, the record refuses it (`COUNT_CONTRADICTS`) and she corrects it.
18
+ Mallory cites the register's units note for a claim it contradicts, is refused twice (`SOURCE_CONTRADICTS`) and files
19
+ it without the note. An independent reviewer checks one claim and fails the other, so the Evidence view shows one
20
+ answer checked and one contested, and `Graph.conduct()` names who kept saying it. It is also on the website
21
+ (`examples/dispute/`, in every site language) and in the README. `hypgraph demo` with no name still builds Broad
22
+ Street.
23
+
3
24
  ## 0.8.1 — 2026-11-01: exact figures, units within their kind
4
25
 
5
26
  An independent audit of 0.8.0 found two defects in the typed checks, both confirmed on the published wheel.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.8.1
3
+ Version: 0.8.2
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/
@@ -136,6 +136,34 @@ CL1 — withdrawn claim · …
136
136
  ⚠ 2 nodes rest on CL1: 1 decision (D1); 1 claim (CL2) — now AT RISK
137
137
  ```
138
138
 
139
+ ## Calling out a bad answer
140
+
141
+ Two agents answer one question from the same road register. One makes an honest mistake and the other lies. The
142
+ record tells them apart, and nobody has to label anyone:
143
+
144
+ <p align="center"><a href="https://hypgraph.com/examples/dispute/"><picture>
145
+ <source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/dispute-dark.png">
146
+ <img src="https://hypgraph.com/assets/readme/dispute-light.png" width="880" alt="The Evidence view of a disputed answer: Ada's claim that the shortest route is 29 km is checked, Mallory's claim that the register is in miles is contested, above the two route counts the instrument filed and the seven register items they rest on">
147
+ </picture></a></p>
148
+ <p align="center"><sub>Standing derived from the record: one answer checked, the other contested. <code>hypgraph demo dispute</code> builds it; <a href="https://hypgraph.com/examples/dispute/">open it live</a>.</sub></p>
149
+
150
+ - **Ada** says the shortest route is 30 km. The instrument counted 1 of the 4 routes as shorter, so the record refuses
151
+ it (`COUNT_CONTRADICTS`). She corrects it to 29 km, which the count backs.
152
+ - **Mallory** says the register is in miles, and cites the register's own units note as support. The note says
153
+ kilometres, so the record refuses it (`SOURCE_CONTRADICTS`). She says it again and is refused again.
154
+
155
+ The record can't catch every lie by itself: once Mallory stops citing the note, her claim goes in. But it keeps what
156
+ she was shown. The reviewer fails her claim against the note, and the conduct log (`hypgraph refusals`,
157
+ `Graph.conduct()`) says who corrected what they were shown and who kept saying it:
158
+
159
+ ```text
160
+ $ hypgraph demo dispute
161
+ …
162
+ What the record says of each author:
163
+ ada the record's own count contradicted a claim of this author's 1 time; the author did not say it again, and corrected it 1 time
164
+ mallory 1 claim of this author's went against a source it cited as support; the author said it again 2 times after being shown, and never corrected it; its last word still says it
165
+ ```
166
+
139
167
  ## Derived, never asserted
140
168
 
141
169
  Nothing in the record is ever *set* as known. How well each claim is known is **derived on every read**:
@@ -104,6 +104,34 @@ CL1 — withdrawn claim · …
104
104
  ⚠ 2 nodes rest on CL1: 1 decision (D1); 1 claim (CL2) — now AT RISK
105
105
  ```
106
106
 
107
+ ## Calling out a bad answer
108
+
109
+ Two agents answer one question from the same road register. One makes an honest mistake and the other lies. The
110
+ record tells them apart, and nobody has to label anyone:
111
+
112
+ <p align="center"><a href="https://hypgraph.com/examples/dispute/"><picture>
113
+ <source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/dispute-dark.png">
114
+ <img src="https://hypgraph.com/assets/readme/dispute-light.png" width="880" alt="The Evidence view of a disputed answer: Ada's claim that the shortest route is 29 km is checked, Mallory's claim that the register is in miles is contested, above the two route counts the instrument filed and the seven register items they rest on">
115
+ </picture></a></p>
116
+ <p align="center"><sub>Standing derived from the record: one answer checked, the other contested. <code>hypgraph demo dispute</code> builds it; <a href="https://hypgraph.com/examples/dispute/">open it live</a>.</sub></p>
117
+
118
+ - **Ada** says the shortest route is 30 km. The instrument counted 1 of the 4 routes as shorter, so the record refuses
119
+ it (`COUNT_CONTRADICTS`). She corrects it to 29 km, which the count backs.
120
+ - **Mallory** says the register is in miles, and cites the register's own units note as support. The note says
121
+ kilometres, so the record refuses it (`SOURCE_CONTRADICTS`). She says it again and is refused again.
122
+
123
+ The record can't catch every lie by itself: once Mallory stops citing the note, her claim goes in. But it keeps what
124
+ she was shown. The reviewer fails her claim against the note, and the conduct log (`hypgraph refusals`,
125
+ `Graph.conduct()`) says who corrected what they were shown and who kept saying it:
126
+
127
+ ```text
128
+ $ hypgraph demo dispute
129
+ …
130
+ What the record says of each author:
131
+ ada the record's own count contradicted a claim of this author's 1 time; the author did not say it again, and corrected it 1 time
132
+ mallory 1 claim of this author's went against a source it cited as support; the author said it again 2 times after being shown, and never corrected it; its last word still says it
133
+ ```
134
+
107
135
  ## Derived, never asserted
108
136
 
109
137
  Nothing in the record is ever *set* as known. How well each claim is known is **derived on every read**:
@@ -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.8.1 (2026-11-01)
112
+ ## Status at 0.8.2 (2026-11-01)
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
@@ -249,6 +249,13 @@ was taken from a changelog without being checked against the code.*
249
249
  > the statement. Untyped mode keeps its documented limits. #104 is merged with green CI (17 of 17 checks). Released as
250
250
  > 0.8.1.
251
251
 
252
+ > **After 0.8.1 (2026-11-01).** The frozen word checks were changed once, for a soundness gap rather than a new
253
+ > wording: a stated count exempted an extreme even when the count was at the extreme's own figure, so an extreme that
254
+ > quoted its own counterexample was accepted. The exemption now holds only for a count about another figure; an adverb
255
+ > before "not" and a bound in the extreme's own direction are read as what they are. Replaying the 1,900 recorded
256
+ > claim writes changes 5, all now refused (4 lies, 1 retraction worded as the claim). #106 is merged with green CI.
257
+ > Released as 0.8.2, with the `dispute` demo (#107).
258
+
252
259
  ## Recommendation: CONDITIONAL GO
253
260
 
254
261
  HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
@@ -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.8.1"
19
+ __version__ = "0.8.2"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -955,22 +955,34 @@ def claimed_extremes(stmt: str) -> list[str]:
955
955
  if not _enumerated(stmt, w) and not _names_stated_count(stmt, w)]
956
956
 
957
957
 
958
+ # an adverb between the copula and its negation: "the highest ... is therefore not 125"
959
+ _ADVERB = r"(?:(?:therefore|thus|hence|then|so|also|still|\w+ly)\s+)?"
958
960
  # the extreme's own predicate denied after it, in its clause: "the highest total value ... is not 130"
959
- _DENIED_EXTREME = re.compile(r"\b(?:is|are|was|were)\s+(?:not|no longer|never)\b|\b(?:isn't|aren't|wasn't|weren't)\b|"
961
+ _DENIED_EXTREME = re.compile(r"\b(?:is|are|was|were)\s+" + _ADVERB + r"(?:not|no longer|never)\b|"
962
+ r"\b(?:isn't|aren't|wasn't|weren't)\b|"
960
963
  # or said to lie beyond a figure: "the true maximum is higher than 98"
961
964
  r"\b(?:is|are|was|were|lies)\s+(?:\w+\s+)?(?:higher|lower|greater|larger|smaller|more|less|"
962
965
  r"above|below|over|under|shorter|longer|beyond)\b", re.I)
963
966
  # or denied before it: "120 is not the highest total value", "isn't the shortest"
964
- _DENIED_BEFORE = re.compile(r"(?:\b(?:is|are|was|were)\s+(?:not|no longer|never)|\b(?:isn't|aren't|wasn't|weren't))"
967
+ _DENIED_BEFORE = re.compile(r"(?:\b(?:is|are|was|were)\s+" + _ADVERB + r"(?:not|no longer|never)|"
968
+ r"\b(?:isn't|aren't|wasn't|weren't))"
965
969
  r"\s+(?:the\s+|a\s+|an\s+)?(?:true\s+|real\s+|actual\s+)?$", re.I)
966
970
  _COPULA = re.compile(r"\b(?:is|are|was|were|isn't|aren't|wasn't|weren't|lies)\b", re.I)
967
971
 
968
972
 
969
- def _denied(after: str) -> bool:
973
+ def _denied(after: str, w: str = "") -> bool:
970
974
  """Does the extreme's own predicate — the first copula after it — deny it or put it beyond a figure? Only that
971
- one: "the highest value is 98 ..., so the true maximum is higher" still claims 98."""
975
+ one: "the highest value is 98 ..., so the true maximum is higher" still claims 98. A bound in the extreme's own
976
+ direction is beyond its figure too: "the highest value is at least 91" (or "91 or more") says the true maximum is
977
+ 91 or above, which what lies beyond 91 doesn't contradict; "the highest is at most 91" still claims a figure."""
972
978
  cop = _COPULA.search(after)
973
- return bool(cop and _DENIED_EXTREME.match(after, cop.start()))
979
+ if not cop:
980
+ return False
981
+ if _DENIED_EXTREME.match(after, cop.start()):
982
+ return True
983
+ own = {s for _a, s, _strict in _comparisons_in(w)}
984
+ pred = after[cop.end():].split(",")[0]
985
+ return bool(own) and any(not strict and s in own for _a, s, strict in _comparisons_in(pred))
974
986
 
975
987
 
976
988
  # a count named for what lies beyond a figure ("fitting selections worth more", "shorter routes connecting") is about
@@ -988,7 +1000,7 @@ def _extremes(stmt: str) -> list[str]:
988
1000
  for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
989
1001
  end = next((x.start() for x in _CLAUSE.finditer(stmt, m.end())), len(stmt))
990
1002
  start = max((x.end() for x in _CLAUSE.finditer(stmt, 0, m.start())), default=0)
991
- if (not _denied(stmt[m.end():end]) and not _DENIED_BEFORE.search(stmt[start:m.start()])
1003
+ if (not _denied(stmt[m.end():end], w) and not _DENIED_BEFORE.search(stmt[start:m.start()])
992
1004
  and re.search(r"\d", stmt[start:end])):
993
1005
  out.append(w)
994
1006
  break
@@ -1011,11 +1023,23 @@ def _count_contradicts(stmt: str, w: str, words: list, m: dict, k: int, n: int)
1011
1023
  words=words + [f"={_fmt(v)}" for v in _subject_numbers(stmt, k, n)])
1012
1024
 
1013
1025
 
1014
- def _qualified(stmt: str, k: int, n: int) -> bool:
1026
+ _THAN_FIGURE = re.compile(r"\bthan\s+\$?(-?\d[\d,]*(?:\.\d+)?)", re.I)
1027
+
1028
+
1029
+ def _qualified(stmt: str, k: int, n: int, figs: frozenset = frozenset(), rv: dict | None = None) -> bool:
1015
1030
  """Does the statement give this count about a figure of its own ("1 of 61 routes are shorter than 26 km")?
1016
- Then it's a count of something beyond that figure, not of exceptions to the extreme it claims."""
1031
+ Then it's a count of something beyond that figure, not of exceptions to the extreme it claims. Not when the
1032
+ figure is the extreme's own: "the shortest route is 30 km, since 1 of 77 are shorter than 30 km" states the very
1033
+ count that shows it false, and quoting the counterexample doesn't make an extreme true."""
1034
+ if figs and rv is not None and stated_figure(rv) in figs:
1035
+ return False
1017
1036
  forms = {f"{k} of {n}", f"{k:,} of {n:,}"}
1018
- return any(any(f in c for f in forms) and re.search(r"\bthan\b", c) for c in _CLAUSE.split(stmt))
1037
+ for c in _CLAUSE.split(stmt):
1038
+ if any(f in c for f in forms) and re.search(r"\bthan\b", c):
1039
+ said = {float(x.replace(",", "")) for x in _THAN_FIGURE.findall(c)}
1040
+ if not (said & set(figs)):
1041
+ return True
1042
+ return False
1019
1043
 
1020
1044
 
1021
1045
  def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
@@ -1032,7 +1056,7 @@ def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
1032
1056
  and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
1033
1057
  or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
1034
1058
  or stated_figure(rv) in figs)
1035
- and not (role == "refute" and _qualified(stmt, *a))]
1059
+ and not (role == "refute" and _qualified(stmt, *a, figs, rv))]
1036
1060
  # a count beyond the extreme's own figure that finds some refutes it, whatever else is bound beside it
1037
1061
  pinned = [(m, a) for m, rv, a, role in rel if role == "refute" and _pinned(m, rv, set(figs))]
1038
1062
  if pinned:
@@ -1061,7 +1085,7 @@ def _extreme_claims(stmt: str) -> list[tuple[str, frozenset]]:
1061
1085
  end = next((x.start() for x in _CLAUSE.finditer(stmt, m.end())), len(stmt))
1062
1086
  start = max((x.end() for x in _CLAUSE.finditer(stmt, 0, m.start())), default=0)
1063
1087
  after = stmt[m.end():end]
1064
- if _denied(after) or _DENIED_BEFORE.search(stmt[start:m.start()]):
1088
+ if _denied(after, w) or _DENIED_BEFORE.search(stmt[start:m.start()]):
1065
1089
  continue
1066
1090
  cop = _COPULA.search(after)
1067
1091
  fig = _FIGURE.match(after, cop.end()) if cop else None
@@ -2,6 +2,7 @@
2
2
  hypgraph — the command line.
3
3
 
4
4
  hypgraph demo build the Broad Street (1854) teaching investigation into ./research.hypgraph
5
+ hypgraph demo dispute an honest mistake and a lie: the record checks one and contests the other
5
6
  hypgraph serve open the HypGraph UI on http://127.0.0.1:8765
6
7
  hypgraph init "Plant growth" --objective "…" --reviewers ada,lin
7
8
  hypgraph add hypothesis statement="Blue light grows cress faster." predictions='["taller after 7 days"]'
@@ -351,19 +352,27 @@ def cmd_import(args) -> int:
351
352
 
352
353
 
353
354
  def cmd_demo(args) -> int:
354
- from .demos import broad_street
355
+ import importlib
356
+
357
+ from .demos import DEMOS
358
+ demo = importlib.import_module(DEMOS[args.name])
355
359
  if Path(args.db).exists():
356
- raise GraphError(f"{args.db} exists — choose another file: hypgraph demo --db broad-street.hypgraph")
357
- g, inv, story = broad_street.build(path=args.db)
360
+ raise GraphError(f"{args.db} exists — choose another file: hypgraph demo {args.name} --db {args.name}.hypgraph")
361
+ g, inv, story = demo.build(path=args.db)
362
+ conduct = g.conduct(inv) if args.name == "dispute" else {}
358
363
  if args.json:
359
- _out(args, {"db": g.path, "inv": inv, "story": story})
364
+ _out(args, {"db": g.path, "inv": inv, "story": story, **({"conduct": conduct} if conduct else {})})
360
365
  return 0
361
366
  for s in story:
362
367
  print(f"{s['at']} {s['actor']:<10} {s['text']}")
363
368
  if "refused" in s:
364
369
  print(" ✗ the record refused it:\n " +
365
370
  s["refused"].replace("\n", "\n "))
366
- print(f"\nBuilt {broad_street.TITLE!r} into {g.path}.\nExplore it: hypgraph --db {args.db} serve"
371
+ if conduct:
372
+ print("\nWhat the record says of each author:")
373
+ for who, row in conduct.items():
374
+ print(f" {who:<10} {row['sentence']}")
375
+ print(f"\nBuilt {demo.TITLE!r} into {g.path}.\nExplore it: hypgraph --db {args.db} serve"
367
376
  f" · hypgraph --db {args.db} report")
368
377
  return 0
369
378
 
@@ -553,7 +562,9 @@ def build_parser() -> argparse.ArgumentParser:
553
562
  "outside the hash chain")
554
563
  s = cmd("import", cmd_import, "rebuild a graph file from a complete bundle")
555
564
  s.add_argument("bundle")
556
- cmd("demo", cmd_demo, "build the Broad Street (1854) teaching investigation")
565
+ s = cmd("demo", cmd_demo, "build a teaching investigation: Broad Street (1854), or a disputed answer")
566
+ s.add_argument("name", nargs="?", default="broad-street", choices=["broad-street", "dispute"],
567
+ help="broad-street (default), or dispute: an honest mistake and a lie, told apart by the record")
557
568
  cmd("schema", cmd_schema, "the node types and relations")
558
569
  s = cmd("refusals", cmd_refusals, "the overclaims the record caught: per author, per rule, fixed or open")
559
570
  s.add_argument("--inv", default="")
@@ -1,3 +1,3 @@
1
1
  """Teaching investigations that ship with HypGraph. ``hypgraph demo`` builds one into a file you can explore."""
2
2
 
3
- DEMOS = {"broad-street": "hypgraph.demos.broad_street"}
3
+ DEMOS = {"broad-street": "hypgraph.demos.broad_street", "dispute": "hypgraph.demos.dispute"}
@@ -0,0 +1,168 @@
1
+ """
2
+ A disputed answer — two parties, one honest mistake, one lie, and the record that tells them apart.
3
+
4
+ A small road register answers one question: how long is the shortest route from Kelso to Holme? Two parties answer
5
+ it, and an independent reviewer reads what the record holds. The record does the counting: every route is
6
+ enumerated by the instrument (author ``lab``), so a claim about "the shortest" meets the count of routes shorter
7
+ than its figure.
8
+
9
+ - **Ada** makes an honest mistake: she adds up the wrong route and says 30 km. The instrument finds 1 of the 4 routes
10
+ shorter, and the record refuses her claim (``COUNT_CONTRADICTS``). She corrects it to 29 km, which the count backs.
11
+ - **Mallory** argues in bad faith: the register's distances are in miles, not kilometres, so no answer stands. She
12
+ cites the register's own units note as support; it says kilometres, and the record refuses her
13
+ (``SOURCE_CONTRADICTS``). She says it again, and is refused again. Then she drops the note and files the same lie
14
+ against Ada's answer, which the record can't check without the note, so it goes in.
15
+ - **Ruth**, the reviewer, checks both against the register: Ada's claim is checked, Mallory's fails.
16
+
17
+ Nobody labels anyone. What the Evidence view shows — Ada's claim *checked*, Mallory's *contested* — and what the
18
+ conduct log says — Ada corrected what she was shown, Mallory kept saying it — are derived from the record on read.
19
+
20
+ from hypgraph.demos import dispute
21
+ g, inv, story = dispute.build()
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from itertools import permutations
27
+
28
+ from ..graph import Graph, GraphError
29
+ from ..policy import Policy
30
+ from .broad_street import DemoClock
31
+
32
+ TITLE = "A disputed answer: the shortest route from Kelso to Holme"
33
+ QUESTION = "How long is the shortest route by road from Kelso to Holme?"
34
+
35
+ # The register: each road, both ways, in kilometres (the note says so).
36
+ ROADS = [("Kelso", "Elstow", 22), ("Elstow", "Holme", 7), ("Kelso", "Lydney", 14), ("Lydney", "Elstow", 9),
37
+ ("Lydney", "Corfe", 12), ("Corfe", "Holme", 19)]
38
+ NOTE = "Register note: every road can be driven in either direction, and every distance in the register is in kilometres."
39
+
40
+
41
+ def routes(a: str = "Kelso", b: str = "Holme") -> list[tuple[list[str], int]]:
42
+ """Every route from ``a`` to ``b`` that visits no town twice, with its length: what the instrument counts over."""
43
+ length = {frozenset((x, y)): d for x, y, d in ROADS}
44
+ towns = sorted({t for x, y, _ in ROADS for t in (x, y)} - {a, b})
45
+ out = []
46
+ for k in range(len(towns) + 1):
47
+ for mid in permutations(towns, k):
48
+ path = [a, *mid, b]
49
+ legs = [frozenset(p) for p in zip(path, path[1:])]
50
+ if all(leg in length for leg in legs):
51
+ out.append((path, sum(length[leg] for leg in legs)))
52
+ return sorted(out, key=lambda r: (r[1], r[0]))
53
+
54
+
55
+ def build(g: Graph | None = None, path: str | None = None) -> tuple[Graph, str, list[dict]]:
56
+ """Build the dispute. Returns (graph, investigation id, story): every step in order, with the refusals."""
57
+ clock = DemoClock("2026-11-02T09:00:00+00:00")
58
+ if g is None:
59
+ g = Graph(path, policy=Policy(reviewers={"ruth"}), clock=clock)
60
+ else:
61
+ g.clock = clock
62
+ story: list[dict] = []
63
+ all_routes = routes()
64
+ n = len(all_routes)
65
+
66
+ def say(who: str, what: str, node: dict | None = None) -> None:
67
+ story.append({"at": clock(), "actor": who, "text": what,
68
+ **({"node": node["id"], "code": node["code"]} if node else {})})
69
+
70
+ def refused(who: str, what: str, attempt) -> None:
71
+ try:
72
+ attempt()
73
+ except GraphError as e:
74
+ story.append({"at": clock(), "actor": who, "text": what, "refused": str(e)})
75
+ return
76
+ raise AssertionError(f"the demo expected a refusal: {what}")
77
+
78
+ curator, lab, ada, mallory, ruth = (g.author(x) for x in ("curator", "lab", "ada", "mallory", "ruth"))
79
+
80
+ # ── the question and the register ────────────────────────────────
81
+ inv = curator.investigation(TITLE, "Settle one disputed answer from the evidence everyone was given.",
82
+ scope=f"The road register below: {len(ROADS)} roads between five towns, and the "
83
+ f"{n} routes from Kelso to Holme that visit no town twice.",
84
+ reviewers=["ruth"])["id"]
85
+ q = curator.add("question", inv, text=QUESTION,
86
+ why="Two parties disagree about the answer; the register holds what is known.")
87
+ say("curator", "Opens the question.", q)
88
+ src = {}
89
+ for i, (x, y, d) in enumerate(ROADS, 1):
90
+ src[f"E{i}"] = curator.add("source", inv, citation=f"Road register, item E{i}.",
91
+ passage=f"Road register: the road between {x} and {y} is {d} km long.",
92
+ reliability="The register given to every party.")["id"]
93
+ src["E7"] = curator.add("source", inv, citation="Road register, item E7: the units note.", passage=NOTE,
94
+ reliability="The register given to every party.")["id"]
95
+ say("curator", f"Files the register: {len(ROADS)} roads and the units note (E1–E7).")
96
+
97
+ def count(figure: int, who: str) -> tuple[str, list[dict]]:
98
+ """The instrument counts the routes shorter than ``figure`` and files it as a run and a result."""
99
+ k = sum(1 for _p, d in all_routes if d < figure)
100
+ run = lab.add("run", inv, kind="lab", state="succeeded",
101
+ name=f"Counted the routes from Kelso to Holme shorter than {figure} km, for {who}.",
102
+ note="Every route that visits no town twice, enumerated from the register.")
103
+ res = lab.add("result", inv, kind="computed", outcome="positive",
104
+ summary=f"{k} of {n} routes from Kelso to Holme are shorter than {figure} km.",
105
+ values={"route_length": float(figure),
106
+ "shorter_routes": {"k": k, "n": n, "figure": figure, "op": "<"}},
107
+ links=[{"rel": "produced", "from": run["id"]}])
108
+ meas = [{"key": "route_length", "name": "route length", "value": float(figure), "result": res["id"]},
109
+ {"key": "shorter_routes", "name": f"routes shorter than {figure} km", "k": k, "n": n,
110
+ "result": res["id"]}]
111
+ say("lab", f"Counts the routes shorter than {figure} km: {k} of {n}.", res)
112
+ return res["id"], meas
113
+
114
+ def cite(*eids: str, why: str) -> list[dict]:
115
+ return [{"rel": "supports", "from": src[e], "why": why} for e in eids]
116
+
117
+ # ── Ada: an honest mistake, shown and corrected ───────────────────
118
+ clock.set("2026-11-02", "10:00:00")
119
+ r30, m30 = count(30, "Ada")
120
+ claim30 = dict(statement="The shortest route from Kelso to Holme is 30 km (Kelso–Lydney–Elstow–Holme).",
121
+ kind="finding", scope=f"The {n} routes from Kelso to Holme in the register.",
122
+ measurements=m30,
123
+ links=[{"rel": "answers", "to": q["id"]},
124
+ *cite("E3", "E4", "E2", why="The three roads of the route, 14 + 9 + 7 km."),
125
+ {"rel": "supports", "from": r30, "why": "The instrument's count at 30 km."}])
126
+ refused("ada", "Says the shortest route is 30 km. The count finds a shorter one.",
127
+ lambda: ada.add("claim", inv, **claim30))
128
+ clock.set("2026-11-02", "10:20:00")
129
+ r29, m29 = count(29, "Ada")
130
+ cl1 = ada.add("claim", inv, kind="finding", measurements=m29,
131
+ statement=f"The shortest route from Kelso to Holme is 29 km (Kelso–Elstow–Holme); none of the {n} "
132
+ f"routes is shorter than 29 km.",
133
+ scope=f"The {n} routes from Kelso to Holme in the register.",
134
+ links=[{"rel": "answers", "to": q["id"]},
135
+ *cite("E1", "E2", why="The two roads of the route, 22 + 7 km."),
136
+ *cite("E7", why="The note: the distances are kilometres, and roads run both ways."),
137
+ {"rel": "supports", "from": r29, "why": "The instrument's count at 29 km."}])
138
+ say("ada", "Corrects it: 29 km, and none of the routes is shorter.", cl1)
139
+
140
+ # ── Mallory: the same lie, said again ─────────────────────────────
141
+ clock.set("2026-11-02", "11:00:00")
142
+ lie = dict(statement="The register's distances are recorded in miles, not kilometres, so Ada's answer for "
143
+ "the shortest route doesn't stand.",
144
+ kind="finding", scope="The road register.",
145
+ links=[{"rel": "answers", "to": q["id"]},
146
+ *cite("E7", why="The register's units note."),
147
+ {"rel": "challenges", "to": cl1["id"], "why": "Disputes the answer's units."}])
148
+ refused("mallory", "Says the distances are in miles, citing the units note as support. The note says kilometres.",
149
+ lambda: mallory.add("claim", inv, **lie))
150
+ clock.set("2026-11-02", "11:10:00")
151
+ refused("mallory", "Says it again.", lambda: mallory.add("claim", inv, **lie))
152
+ clock.set("2026-11-02", "11:20:00")
153
+ cl2 = mallory.add("claim", inv, kind="finding", scope="The road register.",
154
+ statement="The register's distances are recorded in miles, not kilometres, so Ada's answer for "
155
+ "the shortest route doesn't stand.",
156
+ links=[{"rel": "answers", "to": q["id"]},
157
+ *cite("E1", "E2", why="The road lengths the answer adds up."),
158
+ {"rel": "challenges", "to": cl1["id"], "why": "Disputes the answer's units."}])
159
+ say("mallory", "Drops the note and files the same claim against Ada's answer.", cl2)
160
+
161
+ # ── Ruth reads both against the register ──────────────────────────
162
+ clock.set("2026-11-02", "14:00:00")
163
+ ruth.review(cl1["id"], "checked", method="Added up every route in the register and read the units note.",
164
+ notes="29 km by Kelso–Elstow–Holme; no route is shorter; the note says kilometres.")
165
+ ruth.review(cl2["id"], "fail", method="Read the claim against the register's units note (E7).",
166
+ notes="E7 says every distance is in kilometres; nothing in the register says miles.")
167
+ say("ruth", "Checks Ada's claim and fails Mallory's.")
168
+ return g, inv, story
@@ -0,0 +1,52 @@
1
+ """The disputed-answer demo (hypgraph.demos.dispute): an honest mistake and a lie, told apart by the record alone.
2
+
3
+ The README and the website show its Evidence view (one claim checked, the other contested) and its conduct log. These
4
+ tests hold the demo to what those pages say: the refusals come from the record's own checks, the honest party ends
5
+ cleared and the liar ends named, and the whole record verifies."""
6
+ import json
7
+
8
+ from hypgraph import cli
9
+ from hypgraph.demos import DEMOS, dispute
10
+
11
+
12
+ def test_the_routes_are_what_the_story_says():
13
+ assert [d for _p, d in dispute.routes()] == [29, 30, 45, 62]
14
+
15
+
16
+ def test_each_refusal_is_the_records_own_check():
17
+ _g, _inv, story = dispute.build()
18
+ refused = [(s["actor"], s["refused"]) for s in story if "refused" in s]
19
+ assert [a for a, _ in refused] == ["ada", "mallory", "mallory"]
20
+ assert "[COUNT_CONTRADICTS]" in refused[0][1] and "1 of 4" in refused[0][1]
21
+ assert all("[SOURCE_CONTRADICTS]" in r and "kilometres" in r for _a, r in refused[1:])
22
+
23
+
24
+ def test_the_reviewer_checks_one_answer_and_the_other_is_contested():
25
+ g, _inv, story = dispute.build()
26
+ codes = {s["code"]: s["node"] for s in story if s.get("code", "").startswith("CL")}
27
+ assert set(codes) == {"CL1", "CL2"}
28
+ assert g.get(codes["CL1"])["state"]["review"]["state"] == "checked"
29
+ assert g.get(codes["CL2"])["state"]["review"]["state"] == "contested"
30
+
31
+
32
+ def test_the_conduct_log_clears_the_honest_party_and_names_the_liar():
33
+ g, inv, _ = dispute.build()
34
+ c = g.conduct(inv)
35
+ assert c["ada"]["corrected"] == 1 and c["ada"]["reasserted"] == 0
36
+ assert not c["ada"]["still_saying"] and not c["ada"]["ends_false"]
37
+ assert c["mallory"]["by_source"] == 1 and c["mallory"]["reasserted"] == 2 and c["mallory"]["corrected"] == 0
38
+ assert c["mallory"]["still_saying"] and c["mallory"]["ends_false"]
39
+
40
+
41
+ def test_the_record_verifies(tmp_path):
42
+ g, _inv, _ = dispute.build(path=str(tmp_path / "d.hypgraph"))
43
+ assert g.verify()["ok"]
44
+
45
+
46
+ def test_hypgraph_demo_dispute(tmp_path, capsys):
47
+ assert "dispute" in DEMOS
48
+ db = tmp_path / "d.hypgraph"
49
+ assert cli.main(["--db", str(db), "demo", "dispute", "--json"]) == 0
50
+ out = json.loads(capsys.readouterr().out)
51
+ assert out["conduct"]["mallory"]["ends_false"] and not out["conduct"]["ada"]["ends_false"]
52
+ assert sum("refused" in s for s in out["story"]) == 3
@@ -0,0 +1,107 @@
1
+ """An extreme that quotes its own counterexample is still false.
2
+
3
+ "The shortest route is 30 km, since a count finds 1 of 77 shorter than 30 km" states the very count that refutes it.
4
+ A stated count used to exempt an extreme whenever its clause said "k of n ... than", so the counterexample, quoted,
5
+ made the claim pass. A count about a figure of the statement's own ("the shortest is 21 km; 1 of 77 are shorter than
6
+ 26 km") is still not an exception to the extreme. Invariants: quoting the refuting count never turns a refusal into an
7
+ acceptance, with or without the figure on the result; the honest correction that quotes it is accepted; a count about
8
+ another figure stays out of the way."""
9
+ import pytest
10
+
11
+ from hypgraph import Graph, GraphError, Policy
12
+
13
+ SCOPE = "Every simple route between the two towns in the register (77 routes), distances in km."
14
+ WHY = "The route counts bear on the claim."
15
+
16
+
17
+ def outcome(values, statement, meas):
18
+ g = Graph(policy=Policy(reviewers={"critic"}))
19
+ inv = g.author("curator").investigation("Routes", "What is the shortest route between the two towns?")["id"]
20
+ lab = g.author("lab")
21
+ run = lab.add("run", inv, kind="lab", state="succeeded", name="Counted the routes.")
22
+ r = lab.add("result", inv, kind="computed", outcome="positive", summary="Counts of the routes.",
23
+ values=values, links=[{"rel": "produced", "from": run["id"]}])["id"]
24
+ try:
25
+ g.author("a").add("claim", inv, statement=statement, scope=SCOPE,
26
+ measurements=[{**m, "result": r} for m in meas],
27
+ links=[{"rel": "supports", "from": r, "why": WHY}])
28
+ return []
29
+ except GraphError as e:
30
+ return [p["code"] for p in e.problems] or [str(e)]
31
+
32
+
33
+ def shorter(k, figure=None, n=77):
34
+ v = {"k": k, "n": n}
35
+ return v if figure is None else {**v, "figure": figure, "op": "<"}
36
+
37
+
38
+ def case(figure):
39
+ """A route measured at 30 km and the instrument's count of routes shorter than it: 1 of 77. Named as the
40
+ instrument names it, with no figure in the name; the figure on the result or not."""
41
+ values = {"route_length": 30.0, "shorter_routes_connecting": shorter(1, figure)}
42
+ meas = [{"key": "route_length", "name": "route_length", "value": 30.0},
43
+ {"key": "shorter_routes_connecting", "name": "shorter routes connecting", "k": 1, "n": 77}]
44
+ return values, meas
45
+
46
+
47
+ FIGURE = pytest.mark.parametrize("figure", [None, 30], ids=["no figure on the result", "figure on the result"])
48
+
49
+ LIES = ["The shortest route is 30 km, since a count of every route that visits no town twice finds 1 of 77 shorter "
50
+ "than 30 km.",
51
+ "The shortest route is 30 km: 1 of 77 routes are shorter than 30 km.",
52
+ "The shortest route between the towns is 30 km, and 1 of 77 routes is shorter than 30 km."]
53
+
54
+
55
+ @FIGURE
56
+ @pytest.mark.parametrize("s", LIES)
57
+ def test_an_extreme_quoting_its_own_counterexample_is_refused(s, figure):
58
+ assert "COUNT_CONTRADICTS" in outcome(*case(figure)[:1], s, case(figure)[1]), s
59
+
60
+
61
+ @FIGURE
62
+ def test_the_same_extreme_without_the_quote_is_refused(figure):
63
+ assert "COUNT_CONTRADICTS" in outcome(case(figure)[0], "The shortest route is 30 km.", case(figure)[1])
64
+
65
+
66
+ @FIGURE
67
+ @pytest.mark.parametrize("s", ["The shortest route is not 30 km, since a count of every route finds 1 of 77 shorter "
68
+ "than 30 km.",
69
+ "30 km is not the shortest route: 1 of 77 routes are shorter than 30 km."])
70
+ def test_the_correction_that_quotes_the_count_is_accepted(s, figure):
71
+ assert outcome(case(figure)[0], s, case(figure)[1]) == [], s
72
+
73
+
74
+ def test_a_count_about_another_figure_is_not_an_exception_to_the_extreme():
75
+ """The count beyond 26 the statement gives says nothing against "the shortest is 21"; the count at 21 backs it."""
76
+ s = "The shortest route is 21 km; 1 of 77 routes are shorter than 26 km."
77
+ values = {"route_length": 21.0, "shorter_than_21": shorter(0, 21), "shorter_than_26": shorter(1, 26)}
78
+ meas = [{"key": "route_length", "name": "route_length", "value": 21.0},
79
+ {"key": "shorter_than_21", "name": "routes shorter than 21 km", "k": 0, "n": 77},
80
+ {"key": "shorter_than_26", "name": "routes shorter than 26 km", "k": 1, "n": 77}]
81
+ assert "COUNT_CONTRADICTS" not in outcome(values, s, meas)
82
+
83
+
84
+ # Two readings the quoted-count exemption used to hide: once a quoted count no longer excuses an extreme, an honest
85
+ # sentence must not be read as claiming one.
86
+
87
+ @FIGURE
88
+ @pytest.mark.parametrize("s", ["The shortest route is therefore not 30 km: 1 of 77 routes are shorter than 30 km.",
89
+ "1 of 77 routes are shorter than 30 km, so 30 km is therefore not the shortest route.",
90
+ "A count finds 1 of 77 routes shorter than 30 km; the shortest route is thus not 30 km."])
91
+ def test_a_denial_with_an_adverb_is_a_correction(s, figure):
92
+ assert outcome(case(figure)[0], s, case(figure)[1]) == [], s
93
+
94
+
95
+ @FIGURE
96
+ @pytest.mark.parametrize("s", ["The shortest route is at most 30 km: 1 of 77 routes are shorter than 30 km.",
97
+ "The shortest route is 30 km or less; 1 of 77 routes are shorter than 30 km."])
98
+ def test_a_bound_in_the_extremes_own_direction_is_not_its_value(s, figure):
99
+ """At most 30 km, with a route shorter than 30 found, is true: the bound is not a claim that 30 is the shortest."""
100
+ assert "COUNT_CONTRADICTS" not in outcome(case(figure)[0], s, case(figure)[1]), s
101
+
102
+
103
+ @FIGURE
104
+ def test_a_bound_the_other_way_still_claims_its_figure(figure):
105
+ """At least 30 km, with a route shorter than 30 found, is false."""
106
+ s = "The shortest route is at least 30 km."
107
+ assert "COUNT_CONTRADICTS" in outcome(case(figure)[0], s, case(figure)[1])
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