hypgraph 0.8.0__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.0 → hypgraph-0.8.2}/CHANGELOG.md +42 -0
  2. {hypgraph-0.8.0 → hypgraph-0.8.2}/PKG-INFO +29 -1
  3. {hypgraph-0.8.0 → hypgraph-0.8.2}/README.md +28 -0
  4. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/PLAN.md +1 -1
  5. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/RELEASE-READINESS.md +22 -0
  6. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/spec.md +12 -5
  7. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/__init__.py +1 -1
  8. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/assertions.py +77 -21
  9. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/checks.py +59 -14
  10. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/cli.py +17 -6
  11. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/demos/__init__.py +1 -1
  12. hypgraph-0.8.2/src/hypgraph/demos/dispute.py +168 -0
  13. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_assertions.py +127 -8
  14. hypgraph-0.8.2/tests/test_demo_dispute.py +52 -0
  15. hypgraph-0.8.2/tests/test_quoted_counterexample.py +107 -0
  16. {hypgraph-0.8.0 → hypgraph-0.8.2}/.gitignore +0 -0
  17. {hypgraph-0.8.0 → hypgraph-0.8.2}/AGENTS.md +0 -0
  18. {hypgraph-0.8.0 → hypgraph-0.8.2}/CITATION.cff +0 -0
  19. {hypgraph-0.8.0 → hypgraph-0.8.2}/DISCLAIMER.md +0 -0
  20. {hypgraph-0.8.0 → hypgraph-0.8.2}/LICENSE +0 -0
  21. {hypgraph-0.8.0 → hypgraph-0.8.2}/NOTICE +0 -0
  22. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  23. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  24. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/profile-workflows-plan.md +0 -0
  25. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/review-2026-10-24.md +0 -0
  26. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/site-plan.md +0 -0
  27. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/ui-plan.md +0 -0
  28. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/verification.md +0 -0
  29. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/workflows/README.md +0 -0
  30. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/workflows/knowledge.md +0 -0
  31. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/workflows/optimization.md +0 -0
  32. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/workflows/scientific.md +0 -0
  33. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/workflows/software.md +0 -0
  34. {hypgraph-0.8.0 → hypgraph-0.8.2}/docs/workflows/trading.md +0 -0
  35. {hypgraph-0.8.0 → hypgraph-0.8.2}/examples/frameworks/README.md +0 -0
  36. {hypgraph-0.8.0 → hypgraph-0.8.2}/pyproject.toml +0 -0
  37. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/__main__.py +0 -0
  38. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/blind.py +0 -0
  39. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/bundle.py +0 -0
  40. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/coord.py +0 -0
  41. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/demos/broad_street.py +0 -0
  42. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/demos/linked.py +0 -0
  43. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/derive.py +0 -0
  44. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/graph.py +0 -0
  45. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/language.py +0 -0
  46. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/mcp.py +0 -0
  47. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/plan.py +0 -0
  48. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/policy.py +0 -0
  49. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/profiles.py +0 -0
  50. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/references.py +0 -0
  51. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/refusals.py +0 -0
  52. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/report_check.py +0 -0
  53. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/schema.py +0 -0
  54. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/server.py +0 -0
  55. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/skill/SKILL.md +0 -0
  56. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/stats.py +0 -0
  57. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/store.py +0 -0
  58. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/__init__.py +0 -0
  59. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/atlas.css +0 -0
  60. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/atlas.js +0 -0
  61. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.css +0 -0
  62. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  63. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.js +0 -0
  64. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/hypgraph.svg +0 -0
  65. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/index.html +0 -0
  66. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  67. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  68. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  69. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  70. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  71. {hypgraph-0.8.0 → hypgraph-0.8.2}/src/hypgraph/workflow.py +0 -0
  72. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_accept_work_kind.py +0 -0
  73. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_anchoring.py +0 -0
  74. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_blind.py +0 -0
  75. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_blind_oracles.py +0 -0
  76. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_borrowed_words.py +0 -0
  77. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_bound_support.py +0 -0
  78. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_bundle_telemetry.py +0 -0
  79. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_cancel_releases_lease.py +0 -0
  80. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_cited_and_failed_support.py +0 -0
  81. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_contradictions.py +0 -0
  82. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_coord.py +0 -0
  83. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_counts_that_bear.py +0 -0
  84. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_d10.py +0 -0
  85. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_direction_and_units.py +0 -0
  86. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_engine.py +0 -0
  87. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_engine_fixes.py +0 -0
  88. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_figure_said_twice.py +0 -0
  89. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_framework_examples.py +0 -0
  90. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_i18n.py +0 -0
  91. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_incremental.py +0 -0
  92. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_link_translate.py +0 -0
  93. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_linked_records.py +0 -0
  94. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_log_and_check.py +0 -0
  95. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_measure_questions.py +0 -0
  96. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_opposed_answers.py +0 -0
  97. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_option_lead.py +0 -0
  98. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_order_detail.py +0 -0
  99. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_profiles.py +0 -0
  100. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_profiles_041.py +0 -0
  101. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_quantifiers_and_identity.py +0 -0
  102. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_questions_not_assertions.py +0 -0
  103. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_references.py +0 -0
  104. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_refusal_friction.py +0 -0
  105. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_refusal_results.py +0 -0
  106. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_refusals.py +0 -0
  107. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_relation_words.py +0 -0
  108. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_report_answers.py +0 -0
  109. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_rerunnable.py +0 -0
  110. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_restates_scale.py +0 -0
  111. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_server_auth.py +0 -0
  112. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_server_hosts.py +0 -0
  113. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_shape_refusals.py +0 -0
  114. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_stats_not_backtests.py +0 -0
  115. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_stats_values.py +0 -0
  116. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_surfaces.py +0 -0
  117. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_telemetry_stream.py +0 -0
  118. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_trust_boundary.py +0 -0
  119. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_typed_audits.py +0 -0
  120. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_ui_app.py +0 -0
  121. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_ui_engine.py +0 -0
  122. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_uncorrected_effect.py +0 -0
  123. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_unnamed_inputs.py +0 -0
  124. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_verify_complete.py +0 -0
  125. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_verify_lifecycle.py +0 -0
  126. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_verify_scope.py +0 -0
  127. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_verify_scoped_views.py +0 -0
  128. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_verify_trust.py +0 -0
  129. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_verify_views.py +0 -0
  130. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_versions_not_figures.py +0 -0
  131. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_vocabulary_friction.py +0 -0
  132. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_what_a_count_is_of.py +0 -0
  133. {hypgraph-0.8.0 → hypgraph-0.8.2}/tests/test_workflows.py +0 -0
@@ -1,5 +1,47 @@
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
+
24
+ ## 0.8.1 — 2026-11-01: exact figures, units within their kind
25
+
26
+ An independent audit of 0.8.0 found two defects in the typed checks, both confirmed on the published wheel.
27
+
28
+ - **Exact figures (HG-080-01).** Containment of two conditions was decided by probing at each figure ± 1 and the
29
+ midpoints, in floats. Above 2**53 a figure + 1 is the figure, so x > 1e18 looked inside x <= 1e18, and a count of 0
30
+ above 1e18 supported "none below 1e18". Containment is now decided on the pieces the two figures cut the line into,
31
+ with no arithmetic on them, and figures keep their exact value (10**18 + 1 is not 1e18; it renders as itself).
32
+ - **Units within their kind (HG-080-02).** The unit table's kind was dropped, so `lb` (mass) matched `GBP` and
33
+ `sterling` (currency) through the shared name "pound". A unit is now its (kind, unit) readings: `lb` = `lbs` and
34
+ `GBP` = `sterling` still, never across kinds, and an ambiguous `pound` matches only another `pound`; the refusal says
35
+ what it could be and asks for `lb` or `GBP`.
36
+ - **`exactly` decided from bounds.** It was left unresolved unless its set was the counted one or its complement:
37
+ 4 of 4 above F now contradicts "exactly 4 below F". Over one population every verdict now equals enumeration of the
38
+ consistent worlds (sound and complete), and the test checks that.
39
+ - **An extreme stated as a value is two atoms.** In a typed investigation "the longest of the 4 routes is 26 km"
40
+ needs both "none above 26" and "some at 26 or more"; one of them is refused (`ASSERTION_MISSING`). An extreme stated
41
+ as a bound ("the slowest took at least 40 minutes") needs one.
42
+ - Tests: containment against exact rational probes over large, adjacent, subnormal and signed-zero figures; the audit's
43
+ cases through `_decide` and the write gate; units; extremes. The audit's own test file passes (13 of 13).
44
+
3
45
  ## 0.8.0 — 2026-11-01: typed claim assertions
4
46
 
5
47
  Eight independent audits (HG-020 → HG-028) found the same class of failure, each time in a new wording: to decide
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.8.0
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.0 (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
@@ -234,6 +234,28 @@ was taken from a changelog without being checked against the code.*
234
234
  > verified, and the record shows the atoms, not the sentence, as what it checked. Replaying the 1,900 recorded
235
235
  > adversarial entries changes none. #102 is merged with green CI (17 of 17 checks). Released as 0.8.0.
236
236
 
237
+ > **Audit of 0.8.0 (2026-11-01).** The first audit of the typed layer found its architecture sound (GO for a
238
+ > controlled typed-claims pilot) and two narrow defects in its helpers, both reproduced on the published wheel (its
239
+ > test file: 9 failed, the 4 controls passed; the wheel's SHA-256 matched the audit's). Containment was probed in
240
+ > floats at figure ± 1, which above 2**53 is the figure itself, so opposite conditions at 1e18 looked equal and an
241
+ > unsupported "none below 1e18" was supported (HG-080-01). And units dropped their kind, so `lb` matched `GBP` through
242
+ > the shared name "pound" (HG-080-02). Containment is now decided exactly on the pieces of the line, with figures kept
243
+ > as given; a unit is its (kind, unit) readings. Two related gaps closed with them: `exactly` was left open unless its
244
+ > set was the counted one (now decided from bounds, and every verdict equals enumeration over one population), and an
245
+ > extreme stated as a value now needs both its atoms.
246
+ >
247
+ > **Where that stands.** The boundary the audit names stays: prose is matched to atoms by reading words, so a missed
248
+ > part leaves prose unverified, never an atom verified; integrations should consume `asserts` and their verdicts, not
249
+ > the statement. Untyped mode keeps its documented limits. #104 is merged with green CI (17 of 17 checks). Released as
250
+ > 0.8.1.
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
+
237
259
  ## Recommendation: CONDITIONAL GO
238
260
 
239
261
  HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
@@ -290,22 +290,29 @@ smallest detectable difference uses 80% power at two-sided α = 0.05.
290
290
 
291
291
  **Typed claims.** An atom (§2) is decided by its bound count alone; no words are read. Let S_e = {x : x op_e f_e} (what
292
292
  was counted, k of n inside it, n − k outside) and S_a = {x : x op_a f_a} (what is asserted); P_e and P_a the
293
- populations. The property must equal the count's and the unit match it (spellings of one unit are one unit; nothing is
294
- converted); a stated `n` must equal the count's.
293
+ populations. The property must equal the count's and the unit match it: spellings of one unit are one unit within one
294
+ kind of unit (`lb` = `lbs`, `GBP` = `sterling`, never `lb` = `GBP`), a spelling that could be two kinds (`pound`:
295
+ mass or currency) matches only another that could be the same two, and nothing is converted. A stated `n` must equal
296
+ the count's. Figures are compared exactly, as given (an int or a float, at any magnitude), on the pieces of the line
297
+ the two figures cut it into — never by arithmetic on them.
295
298
 
296
299
  - `none` S_a: **supported** when a side of the count with 0 inside it contains S_a and P_a lies within P_e;
297
300
  **contradicted** when a side with more than 0 lies inside S_a and P_e lies within P_a.
298
301
  - `some` S_a: supported when a side with more than 0 lies inside S_a and P_e within P_a; contradicted when a side with
299
302
  0 contains S_a and P_a lies within P_e.
300
- - `all` S_a is `none` of the complement of S_a. `exactly` k needs the same population and the same set (or its
301
- complement, counted n − k), then compares k.
303
+ - `all` S_a is `none` of the complement of S_a.
304
+ - `exactly` k needs the same population. A side of the count lying inside S_a puts at least its count in S_a; a side
305
+ containing S_a, at most its count. k at both bounds is **supported**, k outside them **contradicted**.
306
+
307
+ Over one population these verdicts are complete as well as sound: supported or contradicted exactly when every set of
308
+ values consistent with the count agrees.
302
309
  - Anything else is **unresolved**, and names the field that blocked it.
303
310
 
304
311
  | Code | Refused when |
305
312
  |---|---|
306
313
  | `ASSERTION_CONTRADICTED` | an atom is false by its count. The refusal log tracks it like a count contradiction (§8). |
307
314
  | `ASSERTION_UNRESOLVED` | an atom its count can't settle: another property or unit, a population neither within nor containing the count's, a condition the count's doesn't decide, an untyped count, a malformed atom. |
308
- | `ASSERTION_MISSING` | in a `typed_claims` investigation, the statement has more parts that assert an absolute, an extreme or a count than the claim has atoms. Detection reads words only to ask for atoms; a missed word leaves prose unverified, never an atom verified. The refusal proposes atoms drafted from the statement and the claim's typed counts, which are checked like any other once written. |
315
+ | `ASSERTION_MISSING` | in a `typed_claims` investigation, the statement's parts that assert an absolute, an extreme or a count need more atoms than the claim has: one each, and two for an extreme stated as a value ("the longest is 26 km": none above, some at or above). Detection reads words only to ask for atoms; a missed word leaves prose unverified, never an atom verified. The refusal proposes atoms drafted from the statement and the claim's typed counts, which are checked like any other once written. |
309
316
 
310
317
  Atoms are checked in every investigation that has them. In a `typed_claims` investigation the word interpretation
311
318
  stands down: a measurement's name and key are not read for what was counted, and rule 5 (absolutes, with the
@@ -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.0"
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
@@ -18,7 +18,9 @@ steered one of those inferences. Here the instrument types what it counted and t
18
18
  population a path: ``route/passenger`` lies within ``route``, so a count of 0 over every route also settles
19
19
  "no passenger route"; a count of the passenger routes says nothing of the cargo routes.
20
20
  property an id, compared for equality ("length" is not "speed").
21
- unit compared for equality, spellings of one unit read as one ("km" = "kilometres"); never converted.
21
+ unit compared for equality, spellings of one unit read as one ("km" = "kilometres") within one kind of unit:
22
+ "lb" is mass and "GBP" currency, never the same; "pound", which could be either, matches only
23
+ another spelling that could be either. Never converted.
22
24
  n optional: the population's size as the claim states it; must be the count's n.
23
25
 
24
26
  Each atom is decided on its own, by sets on the number line — S_e = {x : x op_e figure_e} (what was counted) and
@@ -28,7 +30,13 @@ S_a = {x : x op_a figure_a} (what is asserted) — and the paths. A count of k i
28
30
  contradicted when a count above 0 is over a set inside S_a and a population inside the atom's
29
31
  some S_a the mirror: a count above 0 inside S_a supports it, a count of 0 containing S_a contradicts it
30
32
  all S_a none of the complement of S_a
31
- exactly the same set and the same population, then k compared
33
+ exactly the same population; a count over a set inside S_a puts at least that many in it, a count over a
34
+ set containing S_a at most that many: k inside those bounds, at both ends supported, outside them
35
+ contradicted (4 of 4 above F puts at most 0 below it, so "exactly 4 below F" is false)
36
+
37
+ Figures are compared exactly, at any magnitude: containment is decided on the pieces of the number line the two
38
+ figures cut it into (the figures themselves and the open intervals between), with no arithmetic on the figures — a
39
+ probe at figure + 1 is the figure itself above 2**53, which made x > 1e18 look inside x <= 1e18 (HG-080-01).
32
40
 
33
41
  Anything else is unresolved, and the verdict names the field that blocked it. One count can back two atoms only if
34
42
  each checks out against it on its own, so a true absolute can't lend anything to a false one (HG-028-01), and "all"
@@ -76,29 +84,56 @@ def prop(p) -> str | None:
76
84
 
77
85
 
78
86
  def _unit(u) -> frozenset | None:
79
- """A unit as the set of names it could be ("km" → {kilometre}); None when there's none."""
87
+ """A unit as the (kind, unit) pairs it could be ("km" → {(length, kilometre)}, "pound" → {(mass, pound),
88
+ (currency, pound)}); an unknown spelling is itself. None when there's none."""
80
89
  if u is None or (isinstance(u, str) and not u.strip()):
81
90
  return None
82
91
  from .checks import _UNIT_OF # one table of unit spellings, shared with the word checks
83
92
  w = str(u).strip().lower()
84
- return frozenset(c for _f, c in _UNIT_OF.get(w, ())) or frozenset({w})
93
+ return frozenset(_UNIT_OF.get(w, ())) or frozenset({("", w)})
85
94
 
86
95
 
87
96
  def same_unit(a, b) -> bool:
88
- ua, ub = _unit(a), _unit(b)
89
- return (ua is None and ub is None) or (ua is not None and ub is not None and bool(ua & ub))
97
+ """One unit: every reading of one is a reading of the other. "lb" = "lbs" and "GBP" = "sterling", but "lb" is
98
+ never "GBP" (HG-080-02), and an ambiguous "pound" is neither of them."""
99
+ return _unit(a) == _unit(b)
100
+
101
+
102
+ def ambiguous(u) -> list[str]:
103
+ """The kinds of unit an ambiguous spelling could be ("pound": currency, mass); [] when it is one unit."""
104
+ kinds = sorted({k for k, _c in _unit(u) or ()})
105
+ return kinds if len(kinds) > 1 else []
90
106
 
91
107
 
92
108
  def holds(x: float, op: str, f: float) -> bool:
93
109
  return {"<": x < f, "<=": x <= f, "=": x == f, "!=": x != f, ">=": x >= f, ">": x > f}[op]
94
110
 
95
111
 
112
+ def _pieces(*figures) -> list[tuple]:
113
+ """The number line cut at the figures: each figure as a point ("pt", x) and the open intervals between and beyond
114
+ them ("iv", lo, hi), None for an infinite end. Figures are compared as given (Python compares ints and floats
115
+ exactly); nothing is added to or averaged from them."""
116
+ pts = sorted(set(figures))
117
+ out: list[tuple] = [("iv", None, pts[0])]
118
+ for x, y in zip(pts, pts[1:]):
119
+ out += [("pt", x), ("iv", x, y)]
120
+ return out + [("pt", pts[-1]), ("iv", pts[-1], None)]
121
+
122
+
123
+ def _inside(piece: tuple, op: str, f) -> bool:
124
+ """Is a piece of the line inside {x : x op f}? f is one of the cuts, so an open interval lies wholly below or
125
+ wholly above it, and which side decides."""
126
+ if piece[0] == "pt":
127
+ return holds(piece[1], op, f)
128
+ _iv, _lo, hi = piece
129
+ below = hi is not None and hi <= f
130
+ return op in ("<", "<=", "!=") if below else op in (">", ">=", "!=")
131
+
132
+
96
133
  def subset(a: tuple, b: tuple) -> bool:
97
- """Is {x : x a[0] a[1]} inside {x : x b[0] b[1]}? Both sets are unions of intervals with ends at the two figures,
98
- so membership is constant on each piece between them: checking one point per piece decides it."""
99
- pts = sorted({float(a[1]), float(b[1])})
100
- probes = pts + [pts[0] - 1, pts[-1] + 1] + [(x + y) / 2 for x, y in zip(pts, pts[1:])]
101
- return all(holds(x, *b) for x in probes if holds(x, *a))
134
+ """Is {x : x a[0] a[1]} inside {x : x b[0] b[1]}? Both sets are unions of the pieces the two figures cut the line
135
+ into, so it is decided piece by piece, exactly, at any magnitude."""
136
+ return all(_inside(p, *b) for p in _pieces(a[1], b[1]) if _inside(p, *a))
102
137
 
103
138
 
104
139
  def same_set(a: tuple, b: tuple) -> bool:
@@ -122,7 +157,7 @@ def count_fields(rv) -> tuple[dict | None, str]:
122
157
  pop = path(rv["population"])
123
158
  if pop is None:
124
159
  return None, "the count's population is not a path (route/passenger)"
125
- return {"k": int(k), "n": int(n), "op": rv["op"], "figure": float(rv["figure"]), "population": pop,
160
+ return {"k": int(k), "n": int(n), "op": rv["op"], "figure": rv["figure"], "population": pop,
126
161
  "property": prop(rv["property"]), "unit": rv.get("unit")}, ""
127
162
 
128
163
 
@@ -171,9 +206,18 @@ def _cond(a: dict) -> str:
171
206
  return f"{a['property']} {SHOW_OP.get(a['op'], a['op'])} {fig}{u}"
172
207
 
173
208
 
209
+ def whole_form(f):
210
+ """A figure with its exact value, an integral float as an int (26.0 → 26, 1e18 → 10**18): 26 and 26.0 are one
211
+ figure. (Not ``f.is_integer()`` on an int — that is Python 3.12+.)"""
212
+ return f if isinstance(f, int) else int(f) if float(f).is_integer() else f
213
+
214
+
174
215
  def stats_fmt(v) -> str:
216
+ """A figure as it is: ints whole, floats exact (repr round-trips), so a rendered figure never hides a difference."""
217
+ if isinstance(v, int):
218
+ return str(v)
175
219
  v = float(v)
176
- return str(int(v)) if v.is_integer() else f"{v:g}"
220
+ return str(int(v)) if v.is_integer() and abs(v) < 1e15 else repr(v)
177
221
 
178
222
 
179
223
  def render(a: dict) -> str:
@@ -213,8 +257,11 @@ def _decide(a: dict, e: dict) -> tuple[str, str, str]:
213
257
  return "unresolved", "property", (f"the count is of {e['property']}, the assertion is about "
214
258
  f"{prop(a['property'])}")
215
259
  if not same_unit(a.get("unit"), e.get("unit")):
260
+ amb = next(((u, ambiguous(u)) for u in (a.get("unit"), e.get("unit")) if ambiguous(u)), None)
216
261
  return "unresolved", "unit", (f"the count is in {e.get('unit') or 'no unit'}, the assertion in "
217
- f"{a.get('unit') or 'no unit'}: state the figure in the count's unit")
262
+ f"{a.get('unit') or 'no unit'}: state the figure in the count's unit"
263
+ + (f"; '{amb[0]}' could be {' or '.join(amb[1])}, so name the unit "
264
+ "unambiguously (lb, GBP)" if amb else ""))
218
265
  if not (within(pa, pe) or within(pe, pa)):
219
266
  return "unresolved", "population", (f"the count is of {_pop(pe)}, the assertion is about {_pop(pa)}: "
220
267
  "neither lies within the other")
@@ -225,7 +272,7 @@ def _decide(a: dict, e: dict) -> tuple[str, str, str]:
225
272
  f"{_pop(pe)} ({e['n']}) doesn't give")
226
273
  # what was counted, both ways: k inside S_e and n - k outside it
227
274
  sides = [((e["op"], e["figure"]), e["k"]), ((COMPLEMENT[e["op"]], e["figure"]), e["n"] - e["k"])]
228
- q, t = a["quantifier"], (a["op"], float(a["figure"]))
275
+ q, t = a["quantifier"], (a["op"], a["figure"])
229
276
  got = _settle(q, t, sides, pa, pe, a)
230
277
  if got:
231
278
  return got
@@ -242,11 +289,18 @@ def _settle(q: str, t: tuple, sides: list, pa: tuple, pe: tuple, a: dict) -> tup
242
289
  if q == "exactly":
243
290
  if pa != pe:
244
291
  return None
292
+ lo, hi = 0, sum(c for _side, c in sides)
245
293
  for side, c in sides:
246
- if same_set(t, side):
247
- ok = int(a["k"]) == c
248
- return ("supported" if ok else "contradicted", "" if ok else "k",
249
- f"the count finds {c}" + ("" if ok else f", not {int(a['k'])}"))
294
+ if subset(side, t): # everything counted there is in the atom's set
295
+ lo = max(lo, c)
296
+ if subset(t, side): # the atom's set lies inside what was counted there
297
+ hi = min(hi, c)
298
+ want = int(a["k"])
299
+ found = f"the count finds {lo}" if lo == hi else f"the count puts {lo} to {hi} there"
300
+ if lo == hi == want:
301
+ return "supported", "", found
302
+ if want < lo or want > hi:
303
+ return "contradicted", "k", f"{found}, not {want}"
250
304
  return None
251
305
  neg = "that don't" if q == "all" else "that do"
252
306
  if q == "all": # everything meets it: nothing meets its complement
@@ -308,8 +362,10 @@ def key(a) -> str:
308
362
  if atom_shape(a, None)[0]:
309
363
  return json.dumps(a, sort_keys=True, default=str)[:300]
310
364
  u = _unit(a.get("unit"))
365
+ f = a["figure"]
366
+ fig = whole_form(f) # 26 and 26.0 are one figure, exactly
311
367
  return json.dumps([a["quantifier"], "/".join(path(a["population"]) or ()), prop(a["property"]), a["op"],
312
- float(a["figure"]), sorted(u) if u else None, a.get("n"), a.get("k")], default=str)
368
+ fig, sorted(u) if u else None, a.get("n"), a.get("k")], default=str)
313
369
 
314
370
 
315
371
  def numbers(body: dict) -> list[float]:
@@ -375,7 +431,7 @@ def draft(s: Snap, body: dict, inv: str = "") -> list[dict]:
375
431
  pool = [(i, e) for i, e in typed if e["n"] == n]
376
432
  for i, e in [x for x in pool if x[1]["figure"] in figs] or pool:
377
433
  add({"quantifier": "exactly", "population": _pop(e["population"]), "property": e["property"],
378
- "op": e["op"], "figure": int(e["figure"]) if e["figure"].is_integer() else e["figure"],
434
+ "op": e["op"], "figure": whole_form(e["figure"]),
379
435
  "k": k, "n": n, "measurement": i}, e)
380
436
  return out[:6]
381
437
 
@@ -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
@@ -1841,11 +1865,28 @@ def asserting_parts(stmt: str) -> list[str]:
1841
1865
  if L.interrogative(p):
1842
1866
  continue
1843
1867
  quantified = any(_clause_comparisons(m.group("rest"), after_no=True) for m in _QUANT_CMP.finditer(p))
1844
- if L.asserted(L.ABSOLUTE, p) or _extremes(p) or _STATED_COUNT.search(p) or quantified or _NO_NOUN_VERB.search(p):
1868
+ if L.asserted(L.ABSOLUTE, p) or _extremes(p) or _STATED_COUNT.search(p) or quantified or _NO_NOUN_VERB.search(p):
1845
1869
  out.append(p.strip())
1846
1870
  return out
1847
1871
 
1848
1872
 
1873
+ _STATED_FIGURE = re.compile(r"(?<![\w.])\d[\d,]*(?:\.\d+)?")
1874
+ _POPULATION_SIZE = re.compile(r"\b(?:of|among|across|between|from|in|all)\s+(?:the\s+|all\s+|its\s+|these\s+|those\s+)?"
1875
+ r"(\d[\d,]*)\b(?!\s*(?:%|percent|per\s*cent))", re.I)
1876
+
1877
+
1878
+ def atoms_needed(part: str) -> int:
1879
+ """How many atoms a part asks for: an extreme stated as a value ("the longest of the 4 routes is 26 km") is two —
1880
+ nothing above 26 and something at 26 or more; a figure it states only as a bound ("the slowest took at least 40
1881
+ minutes") is one. Over-counting asks for one more atom; it never marks one verified."""
1882
+ if not _extremes(part):
1883
+ return 1
1884
+ bounds = {f for f, _c in _clause_comparisons(part)}
1885
+ sizes = {m.start(1) for m in _POPULATION_SIZE.finditer(part)} # "of the 4 routes" is who, not a figure
1886
+ stated = {float(m.group(0).replace(",", "")) for m in _STATED_FIGURE.finditer(part) if m.start() not in sizes}
1887
+ return 2 if stated - bounds else 1
1888
+
1889
+
1849
1890
  def assertion_issues(s: Snap, body: dict, inv: str, stmt: str, typed: bool) -> list[Problem]:
1850
1891
  """The claim's atoms checked against their counts, and — in a typed investigation — whether the statement asserts
1851
1892
  more than its atoms do."""
@@ -1866,13 +1907,17 @@ def assertion_issues(s: Snap, body: dict, inv: str, stmt: str, typed: bool) -> l
1866
1907
  f"{v['field']}", words=[v["field"]]))
1867
1908
  if typed:
1868
1909
  parts = asserting_parts(stmt)
1869
- if len(parts) > len(atoms):
1910
+ needed = sum(atoms_needed(p) for p in parts)
1911
+ if needed > len(atoms):
1870
1912
  proposed = A.draft(s, body, inv) if not atoms else []
1871
1913
  hint = (" Proposed from the statement and its typed counts — check each before using it: "
1872
1914
  + json.dumps(proposed, separators=(",", ":"))[:900]) if proposed else ""
1873
1915
  out.append(Problem("ASSERTION_MISSING", f"this investigation checks claims as typed assertions: the "
1874
1916
  f"statement asserts {len(parts)} thing{'s' * (len(parts) != 1)} — "
1875
- + "; ".join(f"'{p[:80]}'" for p in parts[:4]) + f" — and the claim gives {len(atoms)} "
1917
+ + "; ".join(f"'{p[:80]}'" for p in parts[:4]) + f" — {needed} atom"
1918
+ + "s" * (needed != 1) + " in all" + (" (an extreme stated as a value is two: none "
1919
+ "above it, some at it or beyond)" if needed > len(parts) else "")
1920
+ + f", and the claim gives {len(atoms)} "
1876
1921
  "atom" + "s" * (len(atoms) != 1) + ". Add an atom for each to asserts: {quantifier "
1877
1922
  "(none | all | some | exactly), population, property, op, figure, unit, n, "
1878
1923
  "measurement}." + hint, fix="add an atom to asserts for each thing the statement "
@@ -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"}