hypgraph 0.8.7__tar.gz → 0.8.8__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 (142) hide show
  1. {hypgraph-0.8.7 → hypgraph-0.8.8}/CHANGELOG.md +55 -0
  2. {hypgraph-0.8.7 → hypgraph-0.8.8}/PKG-INFO +1 -1
  3. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/PLAN.md +1 -1
  4. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/RELEASE-READINESS.md +12 -0
  5. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/checks.py +58 -4
  7. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/graph.py +31 -0
  8. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/language.py +25 -0
  9. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/refusals.py +35 -15
  10. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/report_check.py +4 -3
  11. hypgraph-0.8.8/tests/test_bound_wording.py +88 -0
  12. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_contradictions.py +84 -0
  13. hypgraph-0.8.8/tests/test_implied_absolutes.py +69 -0
  14. hypgraph-0.8.8/tests/test_link_rechecks.py +86 -0
  15. {hypgraph-0.8.7 → hypgraph-0.8.8}/.gitignore +0 -0
  16. {hypgraph-0.8.7 → hypgraph-0.8.8}/AGENTS.md +0 -0
  17. {hypgraph-0.8.7 → hypgraph-0.8.8}/CITATION.cff +0 -0
  18. {hypgraph-0.8.7 → hypgraph-0.8.8}/DISCLAIMER.md +0 -0
  19. {hypgraph-0.8.7 → hypgraph-0.8.8}/LICENSE +0 -0
  20. {hypgraph-0.8.7 → hypgraph-0.8.8}/NOTICE +0 -0
  21. {hypgraph-0.8.7 → hypgraph-0.8.8}/README.md +0 -0
  22. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  23. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  24. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/profile-workflows-plan.md +0 -0
  25. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/review-2026-10-24.md +0 -0
  26. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/site-plan.md +0 -0
  27. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/spec.md +0 -0
  28. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/ui-plan.md +0 -0
  29. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/verification.md +0 -0
  30. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/workflows/README.md +0 -0
  31. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/workflows/knowledge.md +0 -0
  32. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/workflows/optimization.md +0 -0
  33. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/workflows/scientific.md +0 -0
  34. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/workflows/software.md +0 -0
  35. {hypgraph-0.8.7 → hypgraph-0.8.8}/docs/workflows/trading.md +0 -0
  36. {hypgraph-0.8.7 → hypgraph-0.8.8}/examples/frameworks/README.md +0 -0
  37. {hypgraph-0.8.7 → hypgraph-0.8.8}/pyproject.toml +0 -0
  38. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/__main__.py +0 -0
  39. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/assertions.py +0 -0
  40. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/blind.py +0 -0
  41. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/bundle.py +0 -0
  42. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/cli.py +0 -0
  43. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/coord.py +0 -0
  44. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/demos/__init__.py +0 -0
  45. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/demos/broad_street.py +0 -0
  46. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/demos/dispute.py +0 -0
  47. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/demos/linked.py +0 -0
  48. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/derive.py +0 -0
  49. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/mcp.py +0 -0
  50. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/plan.py +0 -0
  51. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/policy.py +0 -0
  52. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/profiles.py +0 -0
  53. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/references.py +0 -0
  54. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/schema.py +0 -0
  55. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/server.py +0 -0
  56. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/skill/SKILL.md +0 -0
  57. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/stats.py +0 -0
  58. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/store.py +0 -0
  59. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/__init__.py +0 -0
  60. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/atlas.css +0 -0
  61. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/atlas.js +0 -0
  62. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.css +0 -0
  63. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  64. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.js +0 -0
  65. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.svg +0 -0
  66. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/index.html +0 -0
  67. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  68. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  69. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  70. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  71. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  72. {hypgraph-0.8.7 → hypgraph-0.8.8}/src/hypgraph/workflow.py +0 -0
  73. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_accept_work_kind.py +0 -0
  74. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_anchoring.py +0 -0
  75. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_assertions.py +0 -0
  76. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_blind.py +0 -0
  77. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_blind_oracles.py +0 -0
  78. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_borrowed_words.py +0 -0
  79. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_bound_support.py +0 -0
  80. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_bundle_telemetry.py +0 -0
  81. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_cancel_releases_lease.py +0 -0
  82. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_cited_and_failed_support.py +0 -0
  83. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_consistent_load.py +0 -0
  84. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_coord.py +0 -0
  85. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_counts_that_bear.py +0 -0
  86. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_d10.py +0 -0
  87. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_demo_dispute.py +0 -0
  88. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_direction_and_units.py +0 -0
  89. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_engine.py +0 -0
  90. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_engine_fixes.py +0 -0
  91. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_figure_said_twice.py +0 -0
  92. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_framework_examples.py +0 -0
  93. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_i18n.py +0 -0
  94. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_incremental.py +0 -0
  95. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_link_translate.py +0 -0
  96. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_linked_records.py +0 -0
  97. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_log_and_check.py +0 -0
  98. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_measure_questions.py +0 -0
  99. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_next_order.py +0 -0
  100. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_opposed_answers.py +0 -0
  101. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_option_lead.py +0 -0
  102. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_order_detail.py +0 -0
  103. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_profiles.py +0 -0
  104. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_profiles_041.py +0 -0
  105. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_quantifiers_and_identity.py +0 -0
  106. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_questions_not_assertions.py +0 -0
  107. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_quoted_counterexample.py +0 -0
  108. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_references.py +0 -0
  109. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_refusal_friction.py +0 -0
  110. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_refusal_results.py +0 -0
  111. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_refusals.py +0 -0
  112. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_relation_words.py +0 -0
  113. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_report_answers.py +0 -0
  114. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_report_significance_denied.py +0 -0
  115. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_rerunnable.py +0 -0
  116. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_restates_scale.py +0 -0
  117. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_server_auth.py +0 -0
  118. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_server_hosts.py +0 -0
  119. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_shape_refusals.py +0 -0
  120. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_statement_is_a_question.py +0 -0
  121. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_statement_slots.py +0 -0
  122. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_stats_not_backtests.py +0 -0
  123. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_stats_values.py +0 -0
  124. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_surfaces.py +0 -0
  125. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_telemetry_stream.py +0 -0
  126. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_trust_boundary.py +0 -0
  127. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_typed_audits.py +0 -0
  128. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_ui_app.py +0 -0
  129. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_ui_engine.py +0 -0
  130. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_ui_sources_focus.py +0 -0
  131. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_uncorrected_effect.py +0 -0
  132. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_unnamed_inputs.py +0 -0
  133. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_verify_complete.py +0 -0
  134. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_verify_lifecycle.py +0 -0
  135. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_verify_scope.py +0 -0
  136. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_verify_scoped_views.py +0 -0
  137. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_verify_trust.py +0 -0
  138. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_verify_views.py +0 -0
  139. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_versions_not_figures.py +0 -0
  140. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_vocabulary_friction.py +0 -0
  141. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_what_a_count_is_of.py +0 -0
  142. {hypgraph-0.8.7 → hypgraph-0.8.8}/tests/test_workflows.py +0 -0
@@ -2,6 +2,61 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.8 — 2026-11-15: the record tells a liar who keeps at it from one who moved on
6
+
7
+ No change to the bundle format. One check closes a back door (a link is checked like the claim's create); the
8
+ rest were found by small showcase experiments on gpt-oss-20b (BC-250), where a paid critic argued against an
9
+ honest researcher and a writer drafted launch headlines from test counts. The conduct log now counts a lie said
10
+ again beside a figure it denies as a repeat, and a new false figure each time as persisting (`conduct()` gains
11
+ `replaced_false`); the bound a refusal suggests is accepted; "flawlessly" and "without a single error" are
12
+ absolutes. Each was checked by replaying the experiments' logs, with no model calls: on 0.8.7 the replays
13
+ reproduce the runs exactly.
14
+
15
+ - **A link that adds support to a claim is checked like the claim's create.** Claim checks ran when a claim was
16
+ created or revised, never when a later `link` added to what it rests on. So any check that reads the support
17
+ (a source that says the opposite, a count against an absolute, a profile's source rules) could be stepped round:
18
+ write the claim on harmless support, then link the rest. In a HypSwarm swarm on gpt-oss-20b (BC-250, 2026-11-10)
19
+ an inconclusive answer was created on a result, and its `cites` link to an unreferenced source was added 53 s
20
+ later. It was reviewed checked. A non-curator's link that changes a claim's support now runs `checks.claim_issues`
21
+ and the profile's claim checks with and without the link. The link is refused only for problems it brings; one the
22
+ claim already had doesn't block an unrelated link. The refusal is logged as `op=link` on the claim. Replayed over
23
+ 270 swarm records on the released profiles, 0 of 5,079 later links would be refused.
24
+ - **A lie said again beside a figure it denies or compares is a repeat.** A count contradiction stored every number
25
+ in the refused statement as part of the lie, including a denied or compared one ("40 km, **not 38 km**", "31 km,
26
+ since the true total **exceeds 30 km**"). `refusals.carried` never counts those as asserted, so the same sentence
27
+ said again was logged as moving off it, not as a repeat. The refusal now stores only the figures the statement
28
+ asserts (`checks.asserted_figures`, one pattern for both sides), and `carried` reads refusals logged before this
29
+ through the refused statement. Found in the spot-the-bad-actor showcase (gpt-oss-20b, BC-250, 2026-11-15): replaying
30
+ its 20 record-arm disputes, one paid critic goes from 0 repeats and 5 moves off to 3 repeats, still saying it at
31
+ the end. No other party's conduct changes.
32
+ - **A new false figure each time reads as persisting, not moving off.** An author refused six times with a different
33
+ figure each time (31, 32, 33 km ...) had every refusal logged as moving off the last one, and `conduct()` summed it
34
+ up as "the author did not say it again". `conduct()` now counts `replaced_false`: claims shown false whose author's
35
+ last word on them was a move into another claim the record also showed false. It is a subset of `moved_off`, so
36
+ existing fields keep their meaning. The sentence says it ("did not say the same claim again, but replaced a
37
+ contradicted claim with another the record also contradicted 5 times, and never corrected it"). Derived on read
38
+ from the log, so existing records read the new way. Found in the spot-the-bad-actor showcase (gpt-oss-20b, BC-250,
39
+ 2026-11-15), where a tie-breaker reading the record acquitted such a critic. Replaying its 20 record-arm disputes:
40
+ 6 of 10 paid critics get a replacement count, no honest party does, and no verdict of the record rule changes.
41
+ - **The honest wording a refusal suggests is accepted.** `ABSOLUTE_UNDERPOWERED` suggests "no occurrences in 8
42
+ trials (95% upper bound 31%)", and `bound()` prints "at most 31%". The record then refused that figure as unbound,
43
+ for two reasons. Statistical figures had to be written to a decimal place, and for k = n the printed bound
44
+ (1 − lower bound) wasn't among the derived numbers at all. A percentage the statement itself labels as a bound
45
+ ("upper bound", "at most", "no more than" …) now matches a bound count's 95% bounds, or their complements, as
46
+ printed. The match is by position, so a labelled bound vouches for nothing else in the sentence. Unlabelled
47
+ figures keep the decimal rule. `check_text` does the same. Found in the say-no-more-than-the-data showcase
48
+ (gpt-oss-20b, BC-250, 2026-11-15). Replaying its 82 headline attempts, 11 that were refused are now accepted, all
49
+ of them correct bounds for their counts, and no other outcome changes.
50
+ - **"Flawlessly" and "without a single error" are absolutes.** They say of how things went what "every" says of
51
+ how many, and they weren't in `language.ABSOLUTE`. So "Real-world files parsed flawlessly" on 76 of 77 went in,
52
+ while "every real-world file parsed" on the same count was refused. Added: flawless(ly), without (a single / any /
53
+ one) error / failure / fault / crash / exception / bug / mistake / glitch / hitch, and error- / failure- / fault- /
54
+ crash- / bug-free. These words describe the manner of their subject, so they cover every one of it only when the
55
+ subject does: "nearly all files parsed flawlessly", "72 of 73 parsed without error" and "flawlessly, only a handful
56
+ slipped" stay true and still go in. Found in the say-no-more-than-the-data showcase (gpt-oss-20b, BC-250,
57
+ 2026-11-15). Replaying its 82 headline attempts, the 4 false drafts that used these words are refused, and no
58
+ other outcome changes. None of the 2,646 HypSwarm records on hand has a claim using them.
59
+
5
60
  ## 0.8.7 — 2026-11-08: a snapshot is loaded at one moment
6
61
 
7
62
  No change to the bundle format or the checks: one read fix and one scope fix, both found running HypSwarm.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.8.7
3
+ Version: 0.8.8
4
4
  Summary: The evidence layer for AI research agents: a hash-chained research record that checks every claim as it is written and derives how well each is known.
5
5
  Project-URL: Homepage, https://hypgraph.com
6
6
  Project-URL: Documentation, https://hypgraph.com/engineers/
@@ -109,7 +109,7 @@ claim's derived standing.
109
109
  Multi-writer sync between graphs (bundles cover sharing), authentication (the host's job; the HTTP server binds to
110
110
  loopback and is read-only unless started with `--writable`), and a hosted service.
111
111
 
112
- ## Status at 0.8.7 (2026-11-08)
112
+ ## Status at 0.8.8 (2026-11-15)
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
@@ -268,6 +268,18 @@ was taken from a changelog without being checked against the code.*
268
268
  > them checked), while a name beside its value passes. Tests: `tests/test_statement_slots.py`,
269
269
  > `tests/test_statement_is_a_question.py`. #115 and #116 are merged with green CI. Released as 0.8.6.
270
270
 
271
+ > **After 0.8.7 (2026-11-15).** No change to the bundle format. A link that adds support to a claim runs the
272
+ > claim checks, so a check can't be stepped round by linking after the create (#121). Four fixes from showcase
273
+ > experiments on gpt-oss-20b (BC-250): a refusal no longer stores a denied or compared figure as part of the lie,
274
+ > so the same sentence said again is a repeat (#122); `conduct()` counts `replaced_false`, a false claim left only
275
+ > for another false one, and says so (#123); a percentage a statement labels as a bound matches a count's 95%
276
+ > bounds as printed, so the honest wording `ABSOLUTE_UNDERPOWERED` and `bound()` suggest is accepted (#124);
277
+ > "flawlessly", "without a single error" and "-free" are absolutes unless their subject is a part (#125).
278
+ > Replaying the experiments' logs: one paid critic's repeats 0 → 3, six get a replacement count, no honest party
279
+ > changes; 11 correct bounds now accepted and 4 false headlines now refused, nothing else moves. Tests:
280
+ > `tests/test_contradictions.py`, `tests/test_bound_wording.py`, `tests/test_implied_absolutes.py`,
281
+ > `tests/test_link_rechecks.py`. #121–#125 are merged with green CI. Released as 0.8.8.
282
+
271
283
  > **After 0.8.6 (2026-11-08).** No change to the bundle format or the checks. A snapshot's tables were each read in
272
284
  > their own implicit transaction, so a second Graph on the same file could commit a node and its edge between the
273
285
  > `nodes` read and the `edges` read; the edge was skipped and its mark advanced past it, lost to that Graph until a
@@ -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.7"
19
+ __version__ = "0.8.8"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -187,7 +187,7 @@ def derived_numbers(bound: list[tuple[dict, dict]]) -> tuple[list[float], list[f
187
187
  k, n = a
188
188
  lo, hi = stats.rate_bounds(k, n)
189
189
  plain += [k / n, 1 - k / n, n - k]
190
- stat += [lo, hi]
190
+ stat += rate_bound_values(k, n)
191
191
  arms.append(a)
192
192
  p = _primary(rv)
193
193
  if p is not None:
@@ -212,6 +212,40 @@ def _matches_stat(printed: tuple, candidates: list[float]) -> bool:
212
212
  return printed[1] >= 1 and _matches(printed, candidates)
213
213
 
214
214
 
215
+ def rate_bound_values(k: int, n: int) -> list[float]:
216
+ """A count's 95% bounds and their complements: the bound on the rate seen, and on the opposite rate ("12 of 12
217
+ read correctly" bounds failures at 1 - lower bound, which is what the refusals and ``bound()`` print)."""
218
+ lo, hi = stats.rate_bounds(k, n)
219
+ return [lo, hi, 1 - lo, 1 - hi]
220
+
221
+
222
+ # a figure the statement itself says is a bound: "(95% upper bound 31%)", "at most 19% of the time", "no more than 4%"
223
+ _LABELLED_BOUND = re.compile(r"\b(?:(?:upper|lower)\s+(?:95\s*%\s+)?(?:confidence\s+)?(?:bound|limit)|at\s+most|"
224
+ r"at\s+least|no\s+more\s+than|no\s+less\s+than|bounded\s+(?:at|by))"
225
+ r"(?:\s+(?:of|is|at|was))?\s*[:=≈]?\s*(?:about\s+|around\s+|roughly\s+|~\s*)?"
226
+ r"(\d[\d,]*(?:\.\d+)?\s*%)", re.I)
227
+
228
+
229
+ def split_bounds(text: str) -> tuple[str, list[tuple]]:
230
+ """(the text with each labelled bound's figure blanked, those figures as ``numbers`` gives them). Split by
231
+ position, so a figure the statement labels as a bound vouches for nothing else that happens to print the same
232
+ ("rose 31% ... (upper bound 31%)")."""
233
+ out, labelled, i = [], [], 0
234
+ for m in _LABELLED_BOUND.finditer(text or ""):
235
+ out.append(text[i:m.start(1)] + " ")
236
+ labelled += numbers(m.group(1))
237
+ i = m.end(1)
238
+ out.append((text or "")[i:])
239
+ return "".join(out), labelled
240
+
241
+
242
+ def _matches_bound(printed: tuple, bounds: list[float]) -> bool:
243
+ """A percentage the statement labels as a bound may match a count's 95% bound as printed, whole numbers too: the
244
+ refusals and ``bound()`` print them that way ("95% upper bound 31%"), and a labelled bound borrows nothing.
245
+ The decimal rule stays for unlabelled figures."""
246
+ return printed[2] and _matches(printed, bounds)
247
+
248
+
215
249
  def _bound(s: Snap, meas: list, stmt: str = "", words: bool = True) -> tuple[list[tuple[dict, dict]], list[Problem]]:
216
250
  """Measurements that check out against their results, and problems with the rest. With ``stmt``, the claim's
217
251
  statement: what a key says it counted must be said by the measurement's name or by the statement. ``words``
@@ -1143,8 +1177,24 @@ def _fmt(v: float) -> str:
1143
1177
 
1144
1178
  def _subject_numbers(stmt: str, k: int, n: int) -> list[float]:
1145
1179
  """The figures a contradicted statement asserts, other than the count itself: what it would have to change to be
1146
- corrected (refusals.conduct reads them to tell a correction from the same claim said again)."""
1147
- return [v for v, *_ in numbers(stmt) if v not in (0, k, n)]
1180
+ corrected (refusals.conduct reads them to tell a correction from the same claim said again). Only figures it
1181
+ asserts (``asserted_figures``): in "the shortest route is 40 km, not 38 km" or "... is 31 km, since the true total
1182
+ exceeds 30 km" the lie is 40 or 31. Storing 38 or 30 with it meant the same sentence said again never counted as a
1183
+ repeat, because a repeat must assert every stored figure and refusals.carried never counts a denied or compared
1184
+ one as asserted. A statement whose every figure is denied or compared keeps them all."""
1185
+ said = asserted_figures(stmt) or {v for v, *_ in numbers(stmt)}
1186
+ return [v for v, *_ in numbers(stmt) if v not in (0, k, n) and v in said]
1187
+
1188
+
1189
+ # a figure the statement doesn't assert but denies or compares against: "not 38", "isn't 38", "instead of 38",
1190
+ # "than 26", "exceeds 30", "above 90" (refusals.carried reads statements through the same pattern)
1191
+ NOT_ASSERTED = re.compile(r"(?:\b(?:not|never|than|above|below|over|under|beyond|exceed(?:s|ing|ed)?|except|"
1192
+ r"instead\s+of)|n't)\s+(?:about\s+|around\s+|roughly\s+)?\$?\d[\d,]*(?:\.\d+)?", re.I)
1193
+
1194
+
1195
+ def asserted_figures(stmt: str) -> set[float]:
1196
+ """The figures a statement asserts: every number it gives, less those it denies or compares against."""
1197
+ return {v for v, *_ in numbers(NOT_ASSERTED.sub(" ", stmt or ""))}
1148
1198
 
1149
1199
 
1150
1200
  # a word a statement denies: "not kilograms", "doesn't apply", "never reached" (the one or two words after it)
@@ -1825,7 +1875,11 @@ def claim_issues(s: Snap, body: dict, support: list[str], inv: str) -> list[Prob
1825
1875
  allowed += [v for v, *_ in numbers(_ORDINAL.sub(r"\1", said))] # "the 14th of June" accounts for "14 June"
1826
1876
  if _CONVENTIONS.search(clean): # "95% interval", "α 0.05", "80% power" are conventions
1827
1877
  allowed += [95.0, 99.0, 90.0, 80.0, 0.05, 0.01]
1828
- loose = [p for p in numbers(clean) if not _matches(p, allowed) and not _matches_stat(p, stat)]
1878
+ rest, labelled = split_bounds(clean) # "(95% upper bound 31%)": a bound the statement names as one
1879
+ bounds = [b for _m, rv in bound if (a := _arm(rv)) for b in rate_bound_values(*a)]
1880
+ loose = [p for p in numbers(rest) if not _matches(p, allowed) and not _matches_stat(p, stat)]
1881
+ loose += [p for p in labelled if not _matches(p, allowed) and not _matches_stat(p, stat)
1882
+ and not _matches_bound(p, bounds)]
1829
1883
  if loose:
1830
1884
  problems.append(Problem("UNBOUND_NUMBER", "numbers in the statement that no bound measurement, the scope or the "
1831
1885
  "investigation's constants account for: " + ", ".join(p[3] for p in loose[:8])
@@ -1161,6 +1161,8 @@ class Graph:
1161
1161
  raise self._sealed(hidden, f"a link that rests {s.nodes[x]['code'] or x} on sealed work")
1162
1162
  if src in s.nodes:
1163
1163
  self._fixed_guard(s, author, src)
1164
+ if author not in self.policy.curators:
1165
+ self._recheck_linked_claims(s, author, (src, rel, dst))
1164
1166
  eid = self._transaction(lambda con: self._insert_edge(con, s, src, rel, dst, data or {}, why, author,
1165
1167
  self.clock()))
1166
1168
  except GraphError as e:
@@ -1181,6 +1183,35 @@ class Graph:
1181
1183
  out["fixes_refusals"] = fixed
1182
1184
  return out
1183
1185
 
1186
+ def _recheck_linked_claims(self, s: D.Snap, author: str, edge: tuple[str, str, str]) -> None:
1187
+ """A link that adds to what a claim rests on is checked as the claim's create would have been: otherwise any
1188
+ check that reads the support is stepped round by writing the claim first and linking the rest after. Only
1189
+ the problems the link brings refuse it; one the claim already had (the record changed around it) doesn't."""
1190
+ for x in dict.fromkeys(edge[::2]):
1191
+ n = s.nodes.get(x)
1192
+ if not n or n["type"] != "claim" or x in s.withdrawn:
1193
+ continue
1194
+ before = self._support_ids(s, x, [])
1195
+ after = self._support_ids(s, x, [edge])
1196
+ if after == before:
1197
+ continue
1198
+ body, inv = n["body"], n["inv"]
1199
+ # the claim's own links stand in for the pending links its create was checked with
1200
+ own = [(e["src"], e["rel"], e["dst"]) for e in s.out.get(x, []) + s.inn.get(x, [])]
1201
+
1202
+ def issues(support: list[str], pending: list[tuple[str, str, str]]) -> list:
1203
+ return list(claim_issues(s, body, support, inv)) + list(profiles.claim_issues(s, inv, body, support,
1204
+ pending))
1205
+ had = {(p.code, p.message) for p in issues(before, own)}
1206
+ new = [p for p in issues(after, own + [edge]) if (p.code, p.message) not in had]
1207
+ if new:
1208
+ found = [p.to_dict() for p in new]
1209
+ refusals.record_refusal(self, author=author, op="link", inv=inv, node=x, body=body, problems=found,
1210
+ support=after)
1211
+ c = n["code"] or x
1212
+ raise GraphError(f"link refused: {c} would rest on it, and {c} with it is refused:\n- "
1213
+ + "\n- ".join(p.text() for p in new), found)
1214
+
1184
1215
  def retract(self, *, author: str, edge: int, why: str) -> dict:
1185
1216
  if len(str(why or "").strip()) < 10:
1186
1217
  raise GraphError("why: say why this link no longer holds")
@@ -29,6 +29,10 @@ ABSOLUTE = re.compile(
29
29
  r"\b(never|always|all|every|none|nothing|no one|nobody|impossible|cannot|can't|proves?|proven|proof|certainly|"
30
30
  r"certain|guaranteed?|guarantees|eliminat(?:es|ed|ing)|entirely|completely|totally|universally|invariably|"
31
31
  r"consistently|without exception|perfect(?:ly)?|solves?|solved|definitively|conclusively|undeniably|"
32
+ # an absolute with no "all" in it: "files parsed flawlessly" on 62 of 63, "without a single error" on 52 of 55
33
+ # (both accepted in the say-no-more-than-the-data showcase, 2026-11-15)
34
+ r"flawless(?:ly)?|without\s+(?:a\s+single\s+|any\s+|one\s+)?(?:error|failure|fault|crash|exception|bug|"
35
+ r"mistake|glitch|hitch)e?s?|(?:error|failure|fault|crash|bug)-free|"
32
36
  r"zero(?!-)"
33
37
  # "no route is longer than 26 km": a universal negative comparison, the same claim as "nothing is longer"; only
34
38
  # where "no" opens its clause ("routes that visit no town twice" is not one); "no route is 26 km or longer" and
@@ -131,10 +135,31 @@ def asserted(pattern: re.Pattern, text: str) -> list[str]:
131
135
  continue
132
136
  if m.group(0).lower() in ("entirely", "wholly", "completely") and _INTERVAL_SIDE.match(text, m.start()):
133
137
  continue
138
+ if _MANNER.fullmatch(m.group(0)) and (_PART_SUBJECT.search(_clause_before(text, m.start()))
139
+ or _EXCEPTIONS.search(text)):
140
+ continue
134
141
  out.append(m.group(0).lower())
135
142
  return list(dict.fromkeys(out))
136
143
 
137
144
 
145
+ # an absolute said of how things went, not of how many: "parsed flawlessly", "without a single error", "error-free".
146
+ # It covers every one of its subject only when the subject does: "nearly all files parsed flawlessly", "72 of 73 files
147
+ # parsed without error" and "flawlessly, only a handful slipped" say so of a part (all four were in the
148
+ # say-no-more-than-the-data showcase, 2026-11-15, and true of their counts)
149
+ _MANNER = re.compile(r"flawless(?:ly)?|without\s.*|\w+-free", re.I | re.S)
150
+ _PART_SUBJECT = re.compile(r"\b(?:(?:almost|nearly|virtually|practically)\s+(?:all|every)|most|mostly|majority|many|"
151
+ r"some|several|all\s+but|\d[\d,]*\s+(?:of|out\s+of)\s+\d)", re.I)
152
+ _EXCEPTIONS = re.compile(r"\b(?:except(?:\s+for)?|all\s+but|but\s+(?:one|two|a\s+few)|apart\s+from|save\s+for|"
153
+ r"other\s+than)\b|\b(?:only|just)\s+(?:a\s+)?(?:few|handful|couple|one|\d+)\b[^.;]*?\b"
154
+ r"(?:slipped|failed|fell|broke|crashed|errored|missed|stumbled|faltered|tripped)\b", re.I)
155
+
156
+
157
+ def _clause_before(text: str, start: int) -> str:
158
+ head = text[:start]
159
+ s0 = max((m.end() for m in _SENTENCE_END.finditer(head)), default=0)
160
+ return head[max((m.end() for m in _CLAUSE.finditer(head, s0)), default=s0):]
161
+
162
+
138
163
  def denied(pattern: re.Pattern, text: str) -> list[str]:
139
164
  """The matches a sentence negates: "not significantly", "no significant difference"."""
140
165
  return [w for w in found(pattern, text) if w not in asserted(pattern, text)]
@@ -125,17 +125,15 @@ def _contradicted(detail: str) -> list[dict]:
125
125
 
126
126
 
127
127
  _CLAUSE = re.compile(r"[;!?]|\.(?!\d)") # a full stop inside a number (3.5) ends no clause
128
- _NOT_ASSERTED = re.compile(r"\b(?:not|than|above|below|over|under|beyond|exceed(?:s|ing)?|except)\s+"
129
- r"(?:about\s+|around\s+)?\d[\d,]*(?:\.\d+)?", re.I)
130
128
 
131
129
 
132
- def carried(problems: list[dict], statement: str, atoms: list | None = None) -> bool:
130
+ def carried(problems: list[dict], statement: str, atoms: list | None = None, was: str = "") -> bool:
133
131
  """Does ``statement`` say again what these contradiction problems showed false? A count contradiction is
134
132
  carried when one clause holds every figure it named (the '=…' words) again AND still asserts an extreme or an
135
133
  absolute about them ("the highest value is 93", "nothing beats 93"): mentioning the old figure in a correction
136
134
  ("1 of 82 routes is shorter than 26 km") is not saying it again. A source contradiction is carried when the
137
135
  statement still denies or doubts the same word, or still says the unit the source ruled out. A typed atom shown
138
- false is carried when ``atoms`` (the new write's) asserts it again."""
136
+ false is carried when ``atoms`` (the new write's) asserts it again. ``was`` is the refused statement."""
139
137
  from . import assertions, checks
140
138
  keys = {assertions.key(a) for a in atoms or []}
141
139
  denied = checks.denied_words(statement or "")
@@ -150,11 +148,15 @@ def carried(problems: list[dict], statement: str, atoms: list | None = None) ->
150
148
  if p.get("code") == "ASSERTION_CONTRADICTED" and {w[5:] for w in words if w.startswith("atom:")} & keys:
151
149
  return True
152
150
  figs = [float(w[1:]) for w in words if w.startswith("=") and w[1:].replace(".", "", 1).isdigit()]
151
+ # only the figures the refused statement asserted: refusals logged before 0.8.8 also stored the ones it denied
152
+ # or compared against ("40 km, not 38 km" gave =40 and =38), so that sentence said again never read as a repeat
153
+ if was and (said := checks.asserted_figures(was)):
154
+ figs = [f for f in figs if any(abs(f - s) < 1e-9 for s in said)] or figs
153
155
  if p.get("code") != "COUNT_CONTRADICTS" or not figs:
154
156
  continue
155
157
  for c in clauses:
156
158
  # a figure the clause negates or compares ("20 km, not 26 km", "greater than 130") isn't asserted
157
- nums = [v for v, *_ in checks.numbers(_NOT_ASSERTED.sub(" ", c))]
159
+ nums = list(checks.asserted_figures(c))
158
160
  # nor is an extreme the clause denies ("the highest value is not 130") or a count's own domain ("a count
159
161
  # of every route finds 1 of 74 shorter"): both used to be logged as the lie said again
160
162
  if all(any(abs(x - f) < 1e-9 for x in nums) for f in figs) and checks.claimed_extremes(c):
@@ -181,7 +183,7 @@ def _note_reassertion(g: "Graph", *, author: str, inv: str, node: str, op: str,
181
183
  con = g._connect()
182
184
  since = (datetime.fromisoformat(g.clock()) - FIX_WINDOW).isoformat(timespec="seconds")
183
185
  opened = _open_contradictions(con, author, inv, since, before=refusal)
184
- hits = [r[0] for r in opened if carried(_contradicted(r[3]), statement, atoms)]
186
+ hits = [r[0] for r in opened if carried(_contradicted(r[3]), statement, atoms, was=r[2])]
185
187
  dropped = _moved_off(con, opened, hits, node=node, statement=statement, answers=answers)
186
188
  if not hits and not dropped:
187
189
  return
@@ -282,13 +284,13 @@ def match_fix(g: "Graph", *, author: str, inv: str, node: str, statement: str, o
282
284
  hits = [r for r in rows if (op == "revise" and r[1] == node) or _overlap(r[2], statement) >= FIX_OVERLAP
283
285
  or _keys_again(r[3], values) or _same_pair(r[2], pair)]
284
286
  # a claim shown false and accepted later saying the same false thing is a re-assertion, not a fix
285
- again = [r[0] for r in hits if carried(_contradicted(r[3]), statement, atoms)]
287
+ again = [r[0] for r in hits if carried(_contradicted(r[3]), statement, atoms, was=r[2])]
286
288
  hits = [r[0] for r in hits if r[0] not in again]
287
289
  # a contradiction this write is about but neither fixes nor says again: the author moved off it
288
290
  opened = [r for r in rows if _contradicted(r[3]) and r[0] not in hits]
289
291
  again += [r[0] for r in opened if r[0] not in again and _addresses(r, node=node, statement=statement,
290
292
  answers=answers)
291
- and carried(_contradicted(r[3]), statement, atoms)]
293
+ and carried(_contradicted(r[3]), statement, atoms, was=r[2])]
292
294
  dropped = _moved_off(con, opened, again, node=node, statement=statement, answers=answers)
293
295
  if not hits and not again and not dropped:
294
296
  return []
@@ -346,11 +348,13 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
346
348
  """Per author: how often the record contradicted a claim of theirs (its own count, or a source they cited, says
347
349
  the opposite), how often they then corrected it, and how often they said the same thing again.
348
350
  {author: {"shown_false", "by_count", "by_source", "corrected", "reasserted", "open", "still_saying", "moved_off",
349
- "ends_false", "sentence"}}. ``shown_false`` is every claim contradicted, ``by_count`` / ``by_source`` how many of
351
+ "replaced_false", "ends_false", "sentence"}}. ``shown_false`` is every claim contradicted, ``by_count`` / ``by_source`` how many of
350
352
  them the record's own count or a cited source contradicted (one claim can be both). A
351
353
  re-assertion that was itself refused is not counted as a new time shown false. ``still_saying``: claims shown false
352
354
  whose author's last word on them (its last fix, repeat or move away, in log order) says them again;
353
- ``moved_off``: claims it last moved away from without a fix; ``ends_false``: the author's last write was itself
355
+ ``moved_off``: claims it last moved away from without a fix; ``replaced_false``: of those, the ones it moved off
356
+ only into another claim the record also showed false (31 km refused, then 32 km, then 33 km: persisting with a
357
+ new figure each time, not moving off); ``ends_false``: the author's last write was itself
354
358
  shown false or said a contradicted claim again (a fresh false claim at the end counts, which ``still_saying``,
355
359
  being per claim, can't see). ``shown_false`` is not a rate of lying: it adds up two kinds of evidence, and a cited
356
360
  source can itself be wrong. Read the parts, and what the author did next, not the total."""
@@ -382,8 +386,12 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
382
386
  r = None
383
387
  if r:
384
388
  root[r] = ref
389
+ # every refusal the record showed false, repeats included: a move off a claim into one of these is no move off
390
+ contra = {rid for rid, kind, _a, _r, codes, _d in rows
391
+ if kind == "refused" and set(json.loads(codes or "[]")) & set(CONTRADICTIONS)}
385
392
  last: dict[int, str] = {} # each claim shown false → its author's last word on it
386
- for _id, kind, _a, ref, _c, _d in rows: # rows come in log order
393
+ into_false: set[int] = set() # … when that last word was a move into another false claim
394
+ for _id, kind, _a, ref, _c, det in rows: # rows come in log order
387
395
  if kind in ("fixed", "reasserted", "dropped") and ref:
388
396
  r, seen = ref, set()
389
397
  while r not in shown and r in root and r not in seen:
@@ -391,10 +399,15 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
391
399
  r = root[r]
392
400
  if r in shown:
393
401
  last[r] = kind
402
+ try:
403
+ to = json.loads(det or "{}").get("refusal")
404
+ except Exception: # noqa: BLE001
405
+ to = None
406
+ (into_false.add if kind == "dropped" and to in contra else into_false.discard)(r)
394
407
  out: dict[str, dict] = {}
395
408
  for rid, a in shown.items():
396
409
  o = out.setdefault(a, {"shown_false": 0, "by_count": 0, "by_source": 0, "by_assertion": 0, "corrected": 0,
397
- "reasserted": 0, "open": 0, "still_saying": 0, "moved_off": 0})
410
+ "reasserted": 0, "open": 0, "still_saying": 0, "moved_off": 0, "replaced_false": 0})
398
411
  o["shown_false"] += 1
399
412
  o["by_count"] += "COUNT_CONTRADICTS" in shown_codes[rid]
400
413
  o["by_source"] += "SOURCE_CONTRADICTS" in shown_codes[rid]
@@ -406,6 +419,7 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
406
419
  o["open"] += not fixed and not again
407
420
  o["still_saying"] += last.get(rid) == "reasserted"
408
421
  o["moved_off"] += last.get(rid) == "dropped"
422
+ o["replaced_false"] += rid in into_false
409
423
  # where each author ends: its last write — a refused one, or an accepted one that fixed, repeated or moved off a
410
424
  # claim shown false — either was shown false (refused as contradicted) or said a contradicted claim again, or not
411
425
  writes: dict[tuple, list] = {}
@@ -432,13 +446,19 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
432
446
  + ([f"a typed count showed an assertion of this author's false {t_} time{'s' * (t_ != 1)}"] if t_ else [])
433
447
  + ([f"{s_} claim{'s' * (s_ != 1)} of this author's went against a source it cited as support"]
434
448
  if s_ else []))
449
+ x = o["replaced_false"]
435
450
  o["sentence"] = (shown + "; "
436
451
  + (f"the author said it again {o['reasserted']} time{'s' * (o['reasserted'] != 1)} after "
437
- "being shown" if o["reasserted"] else "the author did not say it again")
438
- # "never corrected" only beside a repeat: dropping a claim for a different one is no repeat
452
+ "being shown" if o["reasserted"] else "the author did not say the same claim again"
453
+ if x else "the author did not say it again")
454
+ # moving off a false claim only into another false one is persisting, not moving off
455
+ + (f"{', and' if o['reasserted'] else ', but'} replaced a contradicted claim with another the "
456
+ f"record also contradicted {x} time{'s' * (x != 1)}" if x else "")
457
+ # "never corrected" only beside a repeat or a replacement: dropping a false claim for a
458
+ # different, unrefuted one is no repeat
439
459
  + (f", and corrected it {o['corrected']} time{'s' * (o['corrected'] != 1)}"
440
460
  if o["corrected"] else ", and never corrected it"
441
- if o["reasserted"] and (o["still_saying"] or o["ends_false"]) else "")
461
+ if (o["reasserted"] or x) and (o["still_saying"] or o["ends_false"]) else "")
442
462
  + ("; its last word still says it" if o["still_saying"] else
443
463
  "; its last word was contradicted by the record" if o["ends_false"] else
444
464
  "; its last word no longer says it" if o["reasserted"] else ""))
@@ -45,7 +45,7 @@ from typing import TYPE_CHECKING
45
45
  from . import derive as D
46
46
  from . import language as L
47
47
  from . import stats
48
- from .checks import _matches, _matches_stat, derived_numbers, numbers
48
+ from .checks import _matches, _matches_bound, _matches_stat, derived_numbers, numbers, split_bounds
49
49
 
50
50
  if TYPE_CHECKING:
51
51
  from .graph import Graph
@@ -269,11 +269,12 @@ def check(g: "Graph", text: str, inv: str = "") -> dict:
269
269
  if asks: # a question, or the question's own words: citations checked, nothing asserted
270
270
  continue
271
271
  # numbers
272
- for p in numbers(sent):
272
+ rest, labelled = split_bounds(sent) # '95% upper bound 31%': a bound the sentence names as one
273
+ for p, is_bound in [(p, False) for p in numbers(rest)] + [(p, True) for p in labelled]:
273
274
  if _skip_number(p, sent) or re.search(rf"(?<![\w-])[A-Z]{{1,3}}{re.escape(p[3])}(?![\w-])", sent):
274
275
  continue
275
276
  counted["numbers"] += 1
276
- if not _matches(p, plain) and not _matches_stat(p, stat):
277
+ if not _matches(p, plain) and not _matches_stat(p, stat) and not (is_bound and _matches_bound(p, stat)):
277
278
  add(ln, "warning", "REPORT_UNSOURCED_NUMBER", sent,
278
279
  f"{p[3]} isn't in any result, measurement, claim, scope or constant in the record, and doesn't "
279
280
  "follow from them. Bind it to a result, or take it out.")
@@ -0,0 +1,88 @@
1
+ """The honest wording a refusal suggests is accepted.
2
+
3
+ Found in the say-no-more-than-the-data showcase (gpt-oss-20b on the BC-250 boards, 2026-11-15): "the parser never
4
+ crashed" on 0 of 8 test suites was refused with "Honest version: 'no occurrences in 8 trials (95% upper bound 31%)'".
5
+ Writers wrote exactly that, and the record refused it as an unbound number: statistical quantities had to be written
6
+ to a decimal place, and for 14 of 14 the printed bound (1 - lower bound) wasn't among the derived numbers at all. Five
7
+ headlines true of their campaign were never accepted. A percentage the statement itself labels as a bound now matches
8
+ a bound count's 95% bounds as printed; unlabelled figures keep the decimal rule.
9
+ """
10
+ import pytest
11
+
12
+ from hypgraph import Graph, GraphError
13
+
14
+ SCOPE = "The parser test campaign."
15
+ CASES = {"crashed": (0, 8, "test suites in which the parser crashed", "The parser never crashed."),
16
+ "read_14": (14, 14, "text encodings read correctly", "Every text encoding was read correctly."),
17
+ "read_12": (12, 12, "text encodings read correctly", "All text encodings were read correctly.")}
18
+
19
+
20
+ @pytest.fixture
21
+ def env():
22
+ g = Graph()
23
+ inv = g.author("curator").investigation("Parser", "How reliable is the parser?")["id"]
24
+ lab = g.author("lab")
25
+ run = lab.add("run", inv, kind="lab", state="succeeded", name="Ran the parser test campaign.")
26
+ r = lab.add("result", inv, kind="computed", outcome="positive", summary="Parser test campaign.",
27
+ values={k: {"k": a, "n": b} for k, (a, b, _w, _s) in CASES.items()},
28
+ links=[{"rel": "produced", "from": run["id"]}])["id"]
29
+ return g, inv, r
30
+
31
+
32
+ def write(g, inv, r, key, statement):
33
+ k, n, what, _ = CASES[key]
34
+ return g.author("writer").add("claim", inv, statement=statement, scope=SCOPE,
35
+ measurements=[{"name": what, "result": r, "key": key, "k": k, "n": n}],
36
+ links=[{"rel": "supports", "from": r, "why": "The campaign's count."}])
37
+
38
+
39
+ @pytest.mark.parametrize("key", list(CASES))
40
+ def test_the_suggested_honest_version_is_accepted(env, key):
41
+ g, inv, r = env
42
+ with pytest.raises(GraphError) as e:
43
+ write(g, inv, r, key, CASES[key][3])
44
+ fix = next(p["fix"] for p in e.value.problems if p["code"] == "ABSOLUTE_UNDERPOWERED")
45
+ assert "upper bound" in fix
46
+ write(g, inv, r, key, fix[0].upper() + fix[1:] + ".")
47
+
48
+
49
+ @pytest.mark.parametrize("key,statement", [
50
+ ("crashed", "No crashes in 8 test suites; the crash rate is at most 31%."),
51
+ ("read_14", "All 14 text encodings were read correctly: failures happen at most 19% of the time at 95% confidence."),
52
+ ("read_12", "12 of 12 text encodings read correctly (failures: upper 95% confidence bound 22%)."),
53
+ ("crashed", "No crashes in 8 test suites (95% upper bound 31.2%)."),
54
+ ("read_14", "No failures in 14 encodings (95% upper bound 19.3%).")])
55
+ def test_a_bound_written_the_way_bound_prints_it_is_accepted(env, key, statement):
56
+ g, inv, r = env
57
+ write(g, inv, r, key, statement)
58
+
59
+
60
+ @pytest.mark.parametrize("key,statement", [
61
+ ("crashed", "The parser crashed in 31% of the test suites."), # unlabelled: no borrowing
62
+ ("crashed", "No crashes in 8 test suites (95% upper bound 25%)."), # labelled, but not the bound
63
+ ("read_14", "All 14 encodings read; failures happen at most 5% of the time."),
64
+ ("crashed", "Throughput rose 31% over the 8 test suites (upper bound 31%).")])
65
+ def test_a_figure_that_is_not_the_bound_is_still_unbound(env, key, statement):
66
+ g, inv, r = env
67
+ with pytest.raises(GraphError) as e:
68
+ write(g, inv, r, key, statement)
69
+ assert "UNBOUND_NUMBER" in [p["code"] for p in e.value.problems]
70
+
71
+
72
+ def test_a_bound_needs_a_count_behind_it(env):
73
+ g, inv, r = env
74
+ with pytest.raises(GraphError) as e:
75
+ g.author("writer").add("claim", inv, statement="Crashes happen at most 31% of the time.", scope=SCOPE,
76
+ links=[{"rel": "supports", "from": r, "why": "The campaign."}])
77
+ assert "UNBOUND_NUMBER" in [p["code"] for p in e.value.problems]
78
+
79
+
80
+ def test_a_write_up_may_quote_the_bound_as_printed(env):
81
+ g, inv, r = env
82
+ write(g, inv, r, "crashed", "No occurrences in 8 trials (95% upper bound 31%).")
83
+ text = "# Parser\n\nThe parser had no crashes in 8 test suites (95% upper bound 31%).\n"
84
+ issues = g.check_text(text, inv)
85
+ flagged = [i for i in issues.get("findings", []) if i.get("code") == "REPORT_UNSOURCED_NUMBER"]
86
+ assert not flagged, flagged
87
+ issues = g.check_text("# Parser\n\nThe parser had no crashes in 8 test suites (95% upper bound 25%).\n", inv)
88
+ assert [i for i in issues.get("findings", []) if i.get("code") == "REPORT_UNSOURCED_NUMBER"]