hypgraph 0.7.27__tar.gz → 0.7.28__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 (127) hide show
  1. {hypgraph-0.7.27 → hypgraph-0.7.28}/CHANGELOG.md +25 -0
  2. {hypgraph-0.7.27 → hypgraph-0.7.28}/PKG-INFO +1 -1
  3. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/RELEASE-READINESS.md +17 -0
  5. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/checks.py +65 -22
  7. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/language.py +7 -2
  8. hypgraph-0.7.28/tests/test_figure_said_twice.py +121 -0
  9. {hypgraph-0.7.27 → hypgraph-0.7.28}/.gitignore +0 -0
  10. {hypgraph-0.7.27 → hypgraph-0.7.28}/AGENTS.md +0 -0
  11. {hypgraph-0.7.27 → hypgraph-0.7.28}/CITATION.cff +0 -0
  12. {hypgraph-0.7.27 → hypgraph-0.7.28}/DISCLAIMER.md +0 -0
  13. {hypgraph-0.7.27 → hypgraph-0.7.28}/LICENSE +0 -0
  14. {hypgraph-0.7.27 → hypgraph-0.7.28}/NOTICE +0 -0
  15. {hypgraph-0.7.27 → hypgraph-0.7.28}/README.md +0 -0
  16. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  17. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  18. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/profile-workflows-plan.md +0 -0
  19. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/review-2026-10-24.md +0 -0
  20. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/site-plan.md +0 -0
  21. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/spec.md +0 -0
  22. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/verification.md +0 -0
  24. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/workflows/README.md +0 -0
  25. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/workflows/knowledge.md +0 -0
  26. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/workflows/optimization.md +0 -0
  27. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/workflows/scientific.md +0 -0
  28. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/workflows/software.md +0 -0
  29. {hypgraph-0.7.27 → hypgraph-0.7.28}/docs/workflows/trading.md +0 -0
  30. {hypgraph-0.7.27 → hypgraph-0.7.28}/examples/frameworks/README.md +0 -0
  31. {hypgraph-0.7.27 → hypgraph-0.7.28}/pyproject.toml +0 -0
  32. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/blind.py +0 -0
  34. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/bundle.py +0 -0
  35. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/demos/linked.py +0 -0
  40. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/mcp.py +0 -0
  43. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/plan.py +0 -0
  44. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/policy.py +0 -0
  45. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/profiles.py +0 -0
  46. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/references.py +0 -0
  47. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/refusals.py +0 -0
  48. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/report_check.py +0 -0
  49. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/schema.py +0 -0
  50. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/server.py +0 -0
  51. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/skill/SKILL.md +0 -0
  52. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/stats.py +0 -0
  53. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/store.py +0 -0
  54. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/__init__.py +0 -0
  55. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/atlas.css +0 -0
  56. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/atlas.js +0 -0
  57. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.css +0 -0
  58. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  59. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.js +0 -0
  60. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.svg +0 -0
  61. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/index.html +0 -0
  62. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  63. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  64. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  65. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  66. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  67. {hypgraph-0.7.27 → hypgraph-0.7.28}/src/hypgraph/workflow.py +0 -0
  68. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_accept_work_kind.py +0 -0
  69. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_anchoring.py +0 -0
  70. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_blind.py +0 -0
  71. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_blind_oracles.py +0 -0
  72. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_borrowed_words.py +0 -0
  73. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_bound_support.py +0 -0
  74. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_bundle_telemetry.py +0 -0
  75. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_cancel_releases_lease.py +0 -0
  76. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_cited_and_failed_support.py +0 -0
  77. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_contradictions.py +0 -0
  78. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_coord.py +0 -0
  79. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_counts_that_bear.py +0 -0
  80. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_d10.py +0 -0
  81. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_direction_and_units.py +0 -0
  82. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_engine.py +0 -0
  83. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_engine_fixes.py +0 -0
  84. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_framework_examples.py +0 -0
  85. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_i18n.py +0 -0
  86. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_incremental.py +0 -0
  87. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_link_translate.py +0 -0
  88. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_linked_records.py +0 -0
  89. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_log_and_check.py +0 -0
  90. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_measure_questions.py +0 -0
  91. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_opposed_answers.py +0 -0
  92. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_option_lead.py +0 -0
  93. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_order_detail.py +0 -0
  94. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_profiles.py +0 -0
  95. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_profiles_041.py +0 -0
  96. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_quantifiers_and_identity.py +0 -0
  97. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_questions_not_assertions.py +0 -0
  98. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_references.py +0 -0
  99. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_refusal_friction.py +0 -0
  100. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_refusal_results.py +0 -0
  101. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_refusals.py +0 -0
  102. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_relation_words.py +0 -0
  103. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_report_answers.py +0 -0
  104. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_rerunnable.py +0 -0
  105. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_restates_scale.py +0 -0
  106. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_server_auth.py +0 -0
  107. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_server_hosts.py +0 -0
  108. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_shape_refusals.py +0 -0
  109. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_stats_not_backtests.py +0 -0
  110. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_stats_values.py +0 -0
  111. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_surfaces.py +0 -0
  112. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_telemetry_stream.py +0 -0
  113. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_trust_boundary.py +0 -0
  114. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_ui_app.py +0 -0
  115. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_ui_engine.py +0 -0
  116. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_uncorrected_effect.py +0 -0
  117. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_unnamed_inputs.py +0 -0
  118. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_verify_complete.py +0 -0
  119. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_verify_lifecycle.py +0 -0
  120. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_verify_scope.py +0 -0
  121. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_verify_scoped_views.py +0 -0
  122. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_verify_trust.py +0 -0
  123. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_verify_views.py +0 -0
  124. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_versions_not_figures.py +0 -0
  125. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_vocabulary_friction.py +0 -0
  126. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_what_a_count_is_of.py +0 -0
  127. {hypgraph-0.7.27 → hypgraph-0.7.28}/tests/test_workflows.py +0 -0
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.28 — 2026-11-01: a figure said twice, a comparison in another tense
4
+
5
+ Fixes from an independent audit of 0.7.27 (HG-027-01, -02) and from paraphrases written after the first fix; each case
6
+ below failed on 0.7.27 and is now a test (`tests/test_figure_said_twice.py`, 22 of its 34 cases fail on 0.7.27).
7
+
8
+ - **A figure said in a clause that claims nothing doesn't lend it words** (HG-027-01). 0.7.27 read every clause that
9
+ stated a count's figure for what the count was about, so "Speed was recorded above 26 separately; no route is longer
10
+ than 26 km" still let a speed count named "routes longer than 26 km" back the length claim, and cargo counts back
11
+ passenger claims the same way. Of the clauses that state the figure, only those that make the claim (an absolute or
12
+ an extreme) are read now, when any does.
13
+ - **A comparison is read the same in every tense** (HG-027-02). "No route exceeded 26 km" was an absolute, but its
14
+ figure went unread ("exceeds" was read, "exceeded" wasn't), so any complete count backed it, even beside a count of 1
15
+ route over 26. Every stage now reads one list: "exceed" and "surpass" in every tense, "above", "below", "beyond", and
16
+ "over" / "under" after "is" / "was" or the noun "no" opens ("no route under 20 km exists"); not "over 1000 runs",
17
+ which says how many were run. A comparison whose figure still can't be read ("no route is longer than the usual
18
+ one") is backed only by a count that makes that comparison, never by one that compares nothing.
19
+ - **"None of the 4 routes" is still contradicted by its count.** An absolute over a set the sentence enumerates needs no
20
+ count of its own, but it used to skip the check entirely, so a bound count of 1 route over 26 went unread. A count
21
+ that finds an exception now contradicts an enumerated "none" or "neither". An enumerated "all" / "every" stays
22
+ exempt: in the recorded entries it names the set a count ran over ("across all 1,024 selections, 2 fit").
23
+ - **One count backs "no route exceeds 26 km".** "Exceeds" with a figure inside an absolute is a bound the absolute check
24
+ settles, not two measured things compared; the comparison check no longer asks for a second measurement there.
25
+ - Replaying the 1,900 recorded adversarial entries changes none, and every trial's standing is the same. This is
26
+ still word matching: the typed claim layer the audits recommend is not built.
27
+
3
28
  ## 0.7.27 — 2026-11-01: words said elsewhere, comparisons the checks didn't know
4
29
 
5
30
  Fixes from an independent audit of 0.7.26 (HG-026-01, -02); each case below failed on 0.7.26 and is now a test
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.27
3
+ Version: 0.7.28
4
4
  Summary: The evidence layer for AI research agents: a hash-chained research record that checks every claim as it is written and derives how well each is known.
5
5
  Project-URL: Homepage, https://hypgraph.com
6
6
  Project-URL: Documentation, https://hypgraph.com/engineers/
@@ -109,7 +109,7 @@ claim's derived standing.
109
109
  Multi-writer sync between graphs (bundles cover sharing), authentication (the host's job; the HTTP server binds to
110
110
  loopback and is read-only unless started with `--writable`), and a hosted service.
111
111
 
112
- ## Status at 0.7.27 (2026-11-01)
112
+ ## Status at 0.7.28 (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
@@ -196,6 +196,23 @@ was taken from a changelog without being checked against the code.*
196
196
  > **Where that stands.** #98 is merged with green CI (17 of 17 checks). This is still word matching; the typed claim
197
197
  > layer the audits recommend is not built. Released as 0.7.27.
198
198
 
199
+ > **Audit of 0.7.27 (2026-11-01), fixed for 0.7.28.** An eighth independent audit named two defects; both reproduced
200
+ > on the PyPI 0.7.27 wheel (7 of the audit's 9 cases failed, its 2 controls passed). Repeating the count's figure in a
201
+ > clause that claims nothing ("Speed was recorded above 26 separately; no route is longer than 26 km") still let that
202
+ > clause's words say what the count was of (HG-027-01). And "no route exceeded 26 km" was read as an absolute without
203
+ > its figure, so a complete count of anything backed it, even beside a counted counterexample (HG-027-02). Only the
204
+ > clauses that make the claim are read now; every stage reads one list of comparison words in every tense; a
205
+ > comparison whose figure can't be read is backed only by a count that makes it. Holdout paraphrases written after the
206
+ > first fix found three more gaps, fixed before merge: an enumerated "none of the 4 routes" skipped the check, so a
207
+ > counterexample went unread; "over 1000 runs" must not read as a bound; and one honest count was refused for "no route
208
+ > exceeds 26 km". A first version of the enumerated fix refused three honest corrections in the replay ("across all
209
+ > 1,024 selections, 2 fit"), so it covers "none" / "neither" only. Replaying the 1,900 recorded adversarial entries
210
+ > changes none.
211
+ >
212
+ > **Where that stands.** #100 is merged with green CI (17 of 17 checks). An enumerated "all 4 routes" is still not
213
+ > checked against a counterexample. This is still word matching; the typed claim layer the audits recommend is not
214
+ > built. Released as 0.7.28.
215
+
199
216
  ## Recommendation: CONDITIONAL GO
200
217
 
201
218
  HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
@@ -16,7 +16,7 @@ Standing is derived on every read, never stored; every write is an event in a SH
16
16
  their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
17
17
  """
18
18
 
19
- __version__ = "0.7.27"
19
+ __version__ = "0.7.28"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -262,15 +262,19 @@ _SAYING = re.compile(r"[;:!?]|\.(?!\d)|,\s*(?=(?:and|but|while|whereas|although|
262
262
 
263
263
 
264
264
  def _clauses_at(stmt: str, figs: set[float]) -> str:
265
- """The parts of ``stmt`` that state one of ``figs``: what a count against that figure must be about is said there,
266
- not in another sentence. "Speed was recorded separately; no route is longer than 26 km" says nothing about speed
267
- where it says 26 km, so a speed count named "routes longer than 26 km" can't borrow the word (audit HG-026-01).
268
- The whole statement when no part states one."""
265
+ """The parts of ``stmt`` that state one of ``figs`` and make the claim: what a count against that figure must be
266
+ about is said there, not in another sentence. "Speed was recorded separately; no route is longer than 26 km" says
267
+ nothing about speed where it says 26 km, so a speed count named "routes longer than 26 km" can't borrow the word
268
+ (audit HG-026-01), nor can it when the other sentence repeats the 26 (HG-027-01). The whole statement when no part
269
+ states one."""
269
270
  if not stmt or not figs:
270
271
  return stmt
271
272
  parts = [p for p in _SAYING.split(stmt) if p and p.strip()]
272
- hit = [_from_claim(p) for p in parts if {round(v[0], 6) for v in numbers(p)} & {round(f, 6) for f in figs}]
273
- return " . ".join(hit) if hit else stmt
273
+ hit = [p for p in parts if {round(v[0], 6) for v in numbers(p)} & {round(f, 6) for f in figs}]
274
+ # of those, the parts that make a claim ("no route is longer than 26 km"), when any does: "speed was recorded
275
+ # above 26" repeats the figure but claims nothing, so its words can't say what the count was of (audit HG-027-01)
276
+ claims = [p for p in hit if L.ABSOLUTE.search(p) or L.SUPERLATIVE.search(p)]
277
+ return " . ".join(_from_claim(p) for p in claims or hit) if hit else stmt
274
278
 
275
279
 
276
280
  def _from_claim(part: str) -> str:
@@ -335,7 +339,7 @@ _PROVEN = re.compile(r"^prov(?:es?|en|ed)$|^proof$", re.I)
335
339
 
336
340
  # the figure a count is about: "fitting selections worth more than 93", "routes shorter than 26 km" (or, matching a
337
341
  # figure the claim names, "worth_more_93"). A count of nothing beyond 140 says nothing about 93 (audit HG-020-01/02).
338
- _COUNT_FIGURE = re.compile(r"\b(?:(?:" + L.CMP_WORDS + r"|(?!(?:" + L.NOT_CMP + r")\b)[a-z]{3,}er)\s+than|above|below|over|under|beyond|exceed\w*|beat\w*)\s+\$?"
342
+ _COUNT_FIGURE = re.compile(r"\b(?:(?:" + L.CMP_WORDS + r"|(?!(?:" + L.NOT_CMP + r")\b)[a-z]{3,}er)\s+than|above|below|over|under|beyond|exceed\w*|surpass\w*|beat\w*)\s+\$?"
339
343
  r"(-?\d[\d,]*(?:\.\d+)?)", re.I)
340
344
  # the figure a name gives before its comparison or with "at least": "routes 26 km or longer", "routes at least 26 km"
341
345
  _COUNT_FIGURE_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
@@ -353,11 +357,20 @@ def _named_figure(name: str) -> tuple[float, str] | None:
353
357
  return None
354
358
 
355
359
 
356
- _KEY_FIGURE = re.compile(r"\b(?:" + L.CMP_WORDS + r"|above|below|over|under|beyond|exceed\w*|beat\w*)(?:\s+than)?\s+"
360
+ _KEY_FIGURE = re.compile(r"\b(?:" + L.CMP_WORDS + r"|above|below|over|under|beyond|exceed\w*|surpass\w*|beat\w*)"
361
+ r"(?:\s+than)?\s+"
357
362
  r"(\d+(?:\.\d+)?)$", re.I)
358
363
  # the figure an absolute names in its own clause: "nothing worth more than 93 fits", "no route is shorter than 26 km"
359
- _CLAIM_FIGURE = re.compile(r"\b(?:" + L.CMP_WORDS + r"|(?!(?:" + L.NOT_CMP + r")\b)[a-z]{3,}er)\s+than\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\b(?:above|below|beyond|"
360
- r"exceed(?:s|ing)?)\s+\$?(-?\d[\d,]*(?:\.\d+)?)", re.I)
364
+ _CLAIM_FIGURE = re.compile(r"\b(?:" + L.CMP_WORDS + r"|(?!(?:" + L.NOT_CMP + r")\b)[a-z]{3,}er)\s+than\s+\$?"
365
+ r"(-?\d[\d,]*(?:\.\d+)?)|\b(?:" + L.CMP_PREPS + r"|" + L.CMP_VERBS + r")\s+\$?"
366
+ r"(-?\d[\d,]*(?:\.\d+)?)|\b(?:is|are|was|were)\s+(?:over|under)\s+\$?(-?\d[\d,]*(?:\.\d+)?)",
367
+ re.I)
368
+ # "over" / "under" right after the noun "no" opens: "no route under 41 km exists" (``CMP_PREPS``)
369
+ _CLAIM_OVER = re.compile(r"^\s*[a-z]+\s+(?:over|under)\s+\$?(-?\d[\d,]*(?:\.\d+)?)", re.I)
370
+ # a comparison with a figure the checks can't read ("no route is longer than the usual one"): what it compares is
371
+ # still read, so a count that compares nothing, or another way, can't back it (audit HG-027-02)
372
+ _UNREAD_CMP = re.compile(r"\b(?:(?:" + L.CMP_WORDS + r"|(?!(?:" + L.NOT_CMP + r")\b)[a-z]{3,}er)\s+than|"
373
+ + L.CMP_PREPS + r"|" + L.CMP_VERBS + r")\b", re.I)
361
374
  _CLAIM_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
362
375
  r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + L.CMP_WORDS + r")\b", re.I)
363
376
  _SUBCLAUSE = re.compile(r",(?!\d)|[;:!?()]|\.(?!\d)|\b(?:and|but|while|whereas|although|so)\b", re.I)
@@ -395,7 +408,8 @@ def _count_figures(m: dict, rv: dict, figs: set[float] = frozenset()) -> set[flo
395
408
  # routes longer than 26 km says nothing against "the shortest is 26 km" (audit HG-022-01), and none shorter than 26
396
409
  # doesn't back "nothing is longer than 26" (HG-022-02).
397
410
  _DIRECTION_WORDS = {
398
- "": {1: "more greater higher larger bigger above over exceed exceeds exceeding exceeded most highest largest "
411
+ "": {1: "more greater higher larger bigger above over beyond exceed exceeds exceeding exceeded surpass surpasses "
412
+ "surpassed surpassing most highest largest "
399
413
  "greatest biggest maximum max top",
400
414
  -1: "less fewer lower smaller below under least lowest smallest fewest minimum min"},
401
415
  "long": {1: "longer longest", -1: "shorter shortest"},
@@ -492,14 +506,18 @@ def _count_directions(m: dict, rv: dict) -> set[tuple[str, int]]:
492
506
  _ALL = re.compile(r"^(?:always|all|every|universally|invariably|consistently|without exception|100\s*%)$", re.I)
493
507
 
494
508
 
495
- def _clause_comparisons(clause: str) -> list[tuple[float, tuple[str, int, bool] | None]]:
509
+ def _clause_comparisons(clause: str, after_no: bool = False) -> list[tuple[float, tuple[str, int, bool] | None]]:
496
510
  """(figure, comparison) for each comparison against a figure in a claim's clause: "longer than 26 km", "at least
497
- 26", "26 km or longer", "no more than 93", "above 93"; None for one that points nowhere ("beyond 93")."""
511
+ 26", "26 km or longer", "no more than 93", "above 93", "exceeded 26"; None for one that points nowhere. With
512
+ ``after_no``, the clause follows "no", and "over 26" / "under 26" after its noun compare too."""
498
513
  out = []
499
- for h in _CLAIM_FIGURE.finditer(clause):
514
+ hits = list(_CLAIM_FIGURE.finditer(clause)) + list(_CLAIM_OVER.finditer(clause) if after_no else [])
515
+ for h in hits:
500
516
  neg = re.search(r"\b(?:not|no)\s+$", clause[:h.start()], re.I) # "no more than 93" is at most 93
501
- num = (h.group(1) or h.group(2)).replace(",", "")
502
- out += [(float(num), c) for c in _comparisons_in(clause[neg.start() if neg else h.start():h.end()]) or {None}]
517
+ num = next(g for g in h.groups() if g).replace(",", "")
518
+ said = clause[neg.start() if neg else h.start():h.end()]
519
+ said = said if h.re is _CLAIM_FIGURE else said[re.search(r"over|under", said, re.I).start():]
520
+ out += [(float(num), c) for c in _comparisons_in(said) or {None}]
503
521
  for h in _CLAIM_BOUNDARY.finditer(clause):
504
522
  num = (h.group(1) or h.group(2)).replace(",", "")
505
523
  out += [(float(num), c) for c in _comparisons_in(h.group(0)) or {None}]
@@ -510,8 +528,8 @@ def _clause_comparisons(clause: str) -> list[tuple[float, tuple[str, int, bool]
510
528
  MIXED = (frozenset(), frozenset({("mixed", 0, False)}))
511
529
 
512
530
  # "and" or "or" before a comparison with no subject of its own: "shorter than 20 km and longer than 30 km"
513
- _JOINED = re.compile(r"\s+(?:also\s+)?(?:(?:not|no)\s+)?(?:" + _CMP + r"\s+than\b|at\s+(?:least|most)\b|above\b|below\b|"
514
- r"beyond\b|exceed)", re.I)
531
+ _JOINED = re.compile(r"\s+(?:also\s+)?(?:(?:not|no)\s+)?(?:" + _CMP + r"\s+than\b|at\s+(?:least|most)\b|"
532
+ r"(?:" + L.CMP_PREPS + r"|" + L.CMP_VERBS + r")\b)", re.I)
515
533
 
516
534
 
517
535
  def _clause_end(stmt: str, start: int) -> int:
@@ -544,7 +562,10 @@ def _target_groups(stmt: str, w: str) -> list[list[tuple[frozenset, frozenset]]]
544
562
  for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
545
563
  clause = stmt[m.end():_clause_end(stmt, m.end())]
546
564
  targets = [(frozenset({fig}), frozenset() if c is None else frozenset({_negated(c) if every else c}))
547
- for fig, c in _clause_comparisons(clause)]
565
+ for fig, c in _clause_comparisons(clause, after_no=w.lower() == "no")]
566
+ unread = _UNREAD_CMP.search(clause)
567
+ if not targets and unread and (comps := _comparisons_in(unread.group(0))):
568
+ targets = [(frozenset(), frozenset(_negated(c) if every else c for c in comps))]
548
569
  ands = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\band\b", clause, re.I))
549
570
  ors = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\bor\b", clause, re.I))
550
571
  if len(targets) > 1 and ands and ors:
@@ -767,8 +788,16 @@ def _pinned(m: dict, rv: dict, figs: set[float]) -> bool:
767
788
 
768
789
  def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
769
790
  """Every absolute in the statement, and every extreme, checked on its own: a true "nothing worth more than 140
770
- fits" used to end the check, and "the highest is 93" beside it went in unexamined (audit HG-021-02)."""
771
- words = [w for w in L.asserted(L.ABSOLUTE, stmt) if not _enumerated(stmt, w)]
791
+ fits" used to end the check, and "the highest is 93" beside it went in unexamined (audit HG-021-02).
792
+
793
+ An absolute over a set the sentence enumerates ("none of the 4 routes") isn't a generalisation past the sample, so
794
+ it needs no count of its own; but a bound count that finds an exception still contradicts "none" or "neither"."""
795
+ said = L.asserted(L.ABSOLUTE, stmt)
796
+ enumerated = [w for w in said if _enumerated(stmt, w)]
797
+ words = [w for w in said if w not in enumerated]
798
+ # only "none" / "neither", which say nothing in the set is an exception: an enumerated "all" / "every" mostly names
799
+ # the set a count ran over ("across all 1,024 selections, 2 fit and are worth more"), and claims nothing of it
800
+ enumerated = [w for w in enumerated if w.lower() in ("none", "neither") and not _names_stated_count(stmt, w)]
772
801
  if any(str(m.get("key") or "").lower() in ("gap", "bound") for m, _rv in bound):
773
802
  words = [w for w in words if not _PROVEN.match(w)] # an optimum with its bound (gap 0) is proven
774
803
  words = [w for w in words if not _names_counted_set(stmt, w, bound)]
@@ -797,6 +826,10 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
797
826
  "on its own can't show that something meets both. Back it with a count of either one where k is "
798
827
  "0, or count both together", fix="count what meets both comparisons together", words=words)]]
799
828
  out += [p for g in got for p in g if p.text() not in {q.text() for q in out}]
829
+ for w in enumerated:
830
+ for figs, comps in _absolute_targets(stmt, w):
831
+ out += [p for p in _absolute(stmt, scope, body, bound, w, enumerated, set(figs), set(comps))
832
+ if p.code == "COUNT_CONTRADICTS" and p.text() not in {q.text() for q in out}]
800
833
  if not any(p.code == "COUNT_CONTRADICTS" for p in out):
801
834
  out += _superlative_contradicted(stmt, bound)
802
835
  return out
@@ -909,7 +942,7 @@ def _denied(after: str) -> bool:
909
942
 
910
943
  # a count named for what lies beyond a figure ("fitting selections worth more", "shorter routes connecting") is about
911
944
  # any extreme the statement claims, whatever words the statement uses for it ("the highest total value is 99")
912
- _BEYOND = re.compile(r"\b(?:" + L.CMP_WORDS + r"|above|below|exceed\w*|beat\w*)\b", re.I)
945
+ _BEYOND = re.compile(r"\b(?:" + L.CMP_WORDS + r"|above|below|beyond|exceed\w*|surpass\w*|beat\w*)\b", re.I)
913
946
 
914
947
 
915
948
  def _extremes(stmt: str) -> list[str]:
@@ -1392,9 +1425,19 @@ def _p_values(s: Snap, bound: list, support: list[str]) -> list[float]:
1392
1425
  return ps
1393
1426
 
1394
1427
 
1428
+ def _thresholds(stmt: str, comp: list[str]) -> set[str]:
1429
+ """Comparison words that only ever compare with a figure inside an absolute: "no route exceeds 26 km" rules out
1430
+ routes past 26, which the absolute check settles with a count; it doesn't compare two measured things."""
1431
+ if not L.asserted(L.ABSOLUTE, stmt):
1432
+ return set()
1433
+ return {w for w in comp if all(re.match(r"\s+\$?\d", stmt[h.end():])
1434
+ for h in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I))}
1435
+
1436
+
1395
1437
  def _comparisons(s: Snap, stmt: str, bound: list, support: list[str]) -> list[Problem]:
1396
1438
  out = []
1397
1439
  comp = L.asserted(L.COMPARATIVE, stmt)
1440
+ comp = [w for w in comp if w not in _thresholds(stmt, comp)]
1398
1441
  tested = any(isinstance(rv.get("p"), (int, float)) or rv.get("interval") for _m, rv in bound)
1399
1442
  if comp and len([1 for _m, rv in bound if _primary(rv) is not None]) < 2 and not tested \
1400
1443
  and not _measured_claim(s, support):
@@ -19,6 +19,11 @@ CMP_WORDS = ("longer|shorter|more|less|fewer|greater|higher|lower|larger|smaller
19
19
  "cheaper|dearer|earlier|later|heavier|lighter|warmer|hotter|colder|cooler|older|younger|newer|taller|wider|"
20
20
  "narrower|deeper|shallower|louder|quieter|stronger|weaker")
21
21
  NOT_CMP = "other|rather|either|neither|whether|never|ever|over|under|after|per|number|order|matter"
22
+ # the words other than "-er than" that compare with a figure, in every tense ("no route exceeded 26 km" is the same
23
+ # claim as "no route exceeds 26 km": audit HG-027-02). "over" and "under" are apart: "over 1,024 selections" is how
24
+ # many were counted, not a bound, so they compare only after "is" / "was" or a noun that "no" opens.
25
+ CMP_PREPS = "above|below|beyond"
26
+ CMP_VERBS = "exceed(?:s|ed|ing)?|surpass(?:es|ed|ing)?"
22
27
 
23
28
  ABSOLUTE = re.compile(
24
29
  r"\b(never|always|all|every|none|nothing|no one|nobody|impossible|cannot|can't|proves?|proven|proof|certainly|"
@@ -31,8 +36,8 @@ ABSOLUTE = re.compile(
31
36
  # Any comparison word, and any verb before it: "no crate is heavier than 40 kg", "no shift had errors above 5",
32
37
  # "no crate weighs more than 40 kg" (audit HG-026-01: these went unread, so any count backed them)
33
38
  r"|(?<![a-z] )no(?=\s+(?:[a-z]+\s+){1,4}?(?:(?:is|are|was|were)\s+(?:any\s+)?)?(?:at\s+(?:least|most)\s|"
34
- r"\$?\d[\d,.]*(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + CMP_WORDS + r")\b|(?:" + CMP_WORDS + r"|above|below|over|under|"
35
- r"exceed(?:s|ed|ing)?)\b|(?!(?:" + NOT_CMP + r")\b)[a-z]{3,}er\s+than\b)))\b|(?<![\d.])100\s*%", re.I)
39
+ r"\$?\d[\d,.]*(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + CMP_WORDS + r")\b|(?:" + CMP_WORDS + r"|" + CMP_PREPS + r"|over|"
40
+ r"under|" + CMP_VERBS + r")\b|(?!(?:" + NOT_CMP + r")\b)[a-z]{3,}er\s+than\b)))\b|(?<![\d.])100\s*%", re.I)
36
41
  # a superlative names an extreme ("the highest value is 93"): a count of anything beyond it contradicts it
37
42
  SUPERLATIVE = re.compile(
38
43
  r"\b(highest|lowest|largest|smallest|shortest|longest|greatest|fewest|best|worst|maximum|minimum|optimal|"
@@ -0,0 +1,121 @@
1
+ """A figure said twice, a comparison in another tense (audit of 0.7.27, HG-027-01 and -02).
2
+
3
+ What a count must be about is said in the clause that makes the claim. "Speed was recorded above 26 separately; no
4
+ route is longer than 26 km" repeats the 26, but the first clause claims nothing, so its "speed" can't make a speed count
5
+ named "routes longer than 26 km" back the length claim. "No route exceeded 26 km" is the same claim as "no route exceeds
6
+ 26 km": every tense, and "surpassed", "went beyond", "was over", reads its figure, so the counterexample counted at 26
7
+ contradicts it and a count that compares nothing can't back it. Invariants: an extra clause never turns a refusal into
8
+ an acceptance, with the figure repeated or not; a comparison read in one tense is read in all; an honest count backs its
9
+ claim in every wording."""
10
+ import pytest
11
+
12
+ from hypgraph import Graph, GraphError, Policy
13
+
14
+ SCOPE = "The surveyed items, against the stated mark."
15
+
16
+
17
+ def outcome(values, statement, meas=(), scope=SCOPE):
18
+ g = Graph(policy=Policy(reviewers={"critic"}))
19
+ inv = g.author("curator").investigation("Survey", "What do the surveyed items show?")["id"]
20
+ lab = g.author("lab")
21
+ run = lab.add("run", inv, kind="lab", state="succeeded", name="Measured the surveyed items.")
22
+ r = lab.add("result", inv, kind="computed", outcome="positive", summary="Counts of the surveyed items.",
23
+ values=values, links=[{"rel": "produced", "from": run["id"]}])["id"]
24
+ try:
25
+ g.author("a").add("claim", inv, statement=statement, scope=scope,
26
+ measurements=[{**m, "result": r} for m in meas],
27
+ links=[{"rel": "supports", "from": r, "why": "Cited for the claim."}])
28
+ return []
29
+ except GraphError as e:
30
+ return [p["code"] for p in e.problems]
31
+
32
+
33
+ def t(k, figure, op=">", n=4):
34
+ return {"k": k, "n": n, "figure": figure, "op": op}
35
+
36
+
37
+ def M(key, name, k, n=4):
38
+ return {"name": name, "key": key, "k": k, "n": n}
39
+
40
+
41
+ # (frozen values, the relabelled count, the claim with the figure repeated in a clause that claims nothing)
42
+ REPEATED = {
43
+ "speed as length": ({"route_speed_above_26": t(0, 26)}, M("route_speed_above_26", "routes longer than 26 km", 0),
44
+ ["Speed was recorded above 26 separately; no route is longer than 26 km among the 4 routes.",
45
+ "No route is longer than 26 km among the 4 routes; speed was independently recorded above "
46
+ "26."]),
47
+ "cargo as passenger": ({"cargo_route_longer_26": t(0, 26), "passenger_route_longer_26": t(1, 26)},
48
+ M("cargo_route_longer_26", "passenger route longer than 26 km", 0),
49
+ ["Cargo routes were checked against 26 km; no passenger route is longer than 26 km among "
50
+ "the 4 routes."]),
51
+ "depth as temperature": ({"well_depth_above_30": t(0, 30)}, M("well_depth_above_30", "wells warmer than 30 C", 0),
52
+ ["Depth was logged down to 30 m; no well is warmer than 30 C among the 4 wells.",
53
+ "No well is warmer than 30 C among the 4 wells, and depth was logged past 30 too."]),
54
+ "rural as urban": ({"rural_clinic_waits_above_45": t(0, 45), "urban_clinic_waits_above_45": t(2, 45)},
55
+ M("rural_clinic_waits_above_45", "urban clinic waits above 45 minutes", 0),
56
+ ["Rural clinics kept waits under 45 minutes; no urban clinic had waits above 45 minutes among "
57
+ "the 4 clinics."]),
58
+ "age as length": ({"bridge_age_above_60": t(0, 60)}, M("bridge_age_above_60", "bridges longer than 60 m", 0),
59
+ ["Ages above 60 were noted in the register; no bridge is longer than 60 m among the 4 bridges."]),
60
+ }
61
+
62
+
63
+ @pytest.mark.parametrize("case", list(REPEATED))
64
+ def test_a_figure_repeated_elsewhere_does_not_lend_its_clause_words(case):
65
+ """HG-027-01: every clause that said the count's figure was read for what the count was of."""
66
+ values, m, statements = REPEATED[case]
67
+ for s in statements:
68
+ assert "MEASUREMENT_MISMATCH" in outcome(values, s, [m]), s
69
+
70
+
71
+ TENSES = ["No route exceeded 26 km among the 4 routes.", "No route has exceeded 26 km among the 4 routes.",
72
+ "No route surpassed 26 km among the 4 routes.", "No route went beyond 26 km among the 4 routes.",
73
+ "No route was over 26 km among the 4 routes.", "No route exceeds 26 km among the 4 routes.",
74
+ "None of the 4 routes exceeded 26 km.", "Nothing exceeded 26 km among the 4 routes."]
75
+ PROCESSED = M("routes_processed", "routes processed", 4)
76
+
77
+
78
+ @pytest.mark.parametrize("s", TENSES)
79
+ def test_every_tense_finds_the_counterexample(s):
80
+ """HG-027-02: "exceeded" made the claim an absolute but its figure went unread, so a complete count of something
81
+ else backed it over the counterexample bound beside it."""
82
+ values = {"routes_processed": {"k": 4, "n": 4}, "routes_above_26": t(1, 26)}
83
+ assert "COUNT_CONTRADICTS" in outcome(values, s, [PROCESSED, M("routes_above_26", "routes above 26 km", 1)])
84
+
85
+
86
+ @pytest.mark.parametrize("s", [x for x in TENSES if not x.startswith("None")])
87
+ def test_a_count_that_compares_nothing_backs_no_tense(s):
88
+ assert "ABSOLUTE_WITHOUT_COUNT" in outcome({"routes_processed": {"k": 4, "n": 4}}, s, [PROCESSED])
89
+
90
+
91
+ def test_a_comparison_whose_figure_cannot_be_read_is_not_backed_by_a_count_of_something_else():
92
+ s = "No route is longer than the usual one among the 4 routes."
93
+ assert "ABSOLUTE_WITHOUT_COUNT" in outcome({"routes_processed": {"k": 4, "n": 4}}, s, [PROCESSED])
94
+
95
+
96
+ @pytest.mark.parametrize("s", [*TENSES, "Speeds above 26 were logged too; no route exceeded 26 km among the 4 routes."])
97
+ def test_an_honest_count_backs_every_tense(s):
98
+ """One count is enough: "exceeds 26 km" inside "no route …" is a bound the count settles, not two things compared."""
99
+ assert outcome({"routes_above_26": t(0, 26)}, s, [M("routes_above_26", "routes above 26 km", 0)]) == []
100
+
101
+
102
+ @pytest.mark.parametrize("s", ["No route under 20 km exists among the 4 routes.",
103
+ "No route is under 20 km among the 4 routes."])
104
+ def test_under_after_no_is_a_bound(s):
105
+ m = M("routes_under_20", "routes under 20 km", 0)
106
+ assert outcome({"routes_under_20": t(0, 20, "<")}, s, [m]) == []
107
+ assert "COUNT_CONTRADICTS" in outcome({"routes_under_20": t(1, 20, "<")}, s, [{**m, "k": 1}])
108
+
109
+
110
+ def test_over_a_number_of_runs_is_not_a_bound():
111
+ """"over 1000 runs" says how many were run."""
112
+ assert outcome({"checker_failures": {"k": 0, "n": 1000}}, "The checker never failed over 1000 runs.",
113
+ [M("checker_failures", "checker failures", 0, 1000)], scope="1000 runs of the checker.") == []
114
+
115
+
116
+ def test_an_enumerated_none_is_still_contradicted_by_its_count():
117
+ """"none of the 4 routes" needs no count of its own, but one that finds an exception still says it's false."""
118
+ s = "None of the 4 routes is longer than 26 km."
119
+ assert outcome({"routes_above_26": t(0, 26)}, s, [M("routes_above_26", "routes above 26 km", 0)]) == []
120
+ assert "COUNT_CONTRADICTS" in outcome({"routes_above_26": t(1, 26)}, s,
121
+ [M("routes_above_26", "routes above 26 km", 1)])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes