hypgraph 0.7.26__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.26 → hypgraph-0.7.28}/CHANGELOG.md +48 -0
  2. {hypgraph-0.7.26 → hypgraph-0.7.28}/PKG-INFO +1 -1
  3. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/RELEASE-READINESS.md +32 -0
  5. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/checks.py +128 -37
  7. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/language.py +18 -5
  8. hypgraph-0.7.28/tests/test_borrowed_words.py +130 -0
  9. hypgraph-0.7.28/tests/test_figure_said_twice.py +121 -0
  10. {hypgraph-0.7.26 → hypgraph-0.7.28}/.gitignore +0 -0
  11. {hypgraph-0.7.26 → hypgraph-0.7.28}/AGENTS.md +0 -0
  12. {hypgraph-0.7.26 → hypgraph-0.7.28}/CITATION.cff +0 -0
  13. {hypgraph-0.7.26 → hypgraph-0.7.28}/DISCLAIMER.md +0 -0
  14. {hypgraph-0.7.26 → hypgraph-0.7.28}/LICENSE +0 -0
  15. {hypgraph-0.7.26 → hypgraph-0.7.28}/NOTICE +0 -0
  16. {hypgraph-0.7.26 → hypgraph-0.7.28}/README.md +0 -0
  17. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  18. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  19. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/profile-workflows-plan.md +0 -0
  20. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/review-2026-10-24.md +0 -0
  21. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/site-plan.md +0 -0
  22. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/spec.md +0 -0
  23. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/ui-plan.md +0 -0
  24. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/verification.md +0 -0
  25. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/workflows/README.md +0 -0
  26. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/workflows/knowledge.md +0 -0
  27. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/workflows/optimization.md +0 -0
  28. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/workflows/scientific.md +0 -0
  29. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/workflows/software.md +0 -0
  30. {hypgraph-0.7.26 → hypgraph-0.7.28}/docs/workflows/trading.md +0 -0
  31. {hypgraph-0.7.26 → hypgraph-0.7.28}/examples/frameworks/README.md +0 -0
  32. {hypgraph-0.7.26 → hypgraph-0.7.28}/pyproject.toml +0 -0
  33. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/__main__.py +0 -0
  34. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/blind.py +0 -0
  35. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/bundle.py +0 -0
  36. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/cli.py +0 -0
  37. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/coord.py +0 -0
  38. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/demos/__init__.py +0 -0
  39. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/demos/broad_street.py +0 -0
  40. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/demos/linked.py +0 -0
  41. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/derive.py +0 -0
  42. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/graph.py +0 -0
  43. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/mcp.py +0 -0
  44. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/plan.py +0 -0
  45. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/policy.py +0 -0
  46. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/profiles.py +0 -0
  47. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/references.py +0 -0
  48. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/refusals.py +0 -0
  49. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/report_check.py +0 -0
  50. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/schema.py +0 -0
  51. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/server.py +0 -0
  52. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/skill/SKILL.md +0 -0
  53. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/stats.py +0 -0
  54. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/store.py +0 -0
  55. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/__init__.py +0 -0
  56. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/atlas.css +0 -0
  57. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/atlas.js +0 -0
  58. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.css +0 -0
  59. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  60. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.js +0 -0
  61. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/hypgraph.svg +0 -0
  62. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/index.html +0 -0
  63. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  64. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  65. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  66. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  67. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  68. {hypgraph-0.7.26 → hypgraph-0.7.28}/src/hypgraph/workflow.py +0 -0
  69. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_accept_work_kind.py +0 -0
  70. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_anchoring.py +0 -0
  71. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_blind.py +0 -0
  72. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_blind_oracles.py +0 -0
  73. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_bound_support.py +0 -0
  74. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_bundle_telemetry.py +0 -0
  75. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_cancel_releases_lease.py +0 -0
  76. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_cited_and_failed_support.py +0 -0
  77. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_contradictions.py +0 -0
  78. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_coord.py +0 -0
  79. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_counts_that_bear.py +0 -0
  80. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_d10.py +0 -0
  81. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_direction_and_units.py +0 -0
  82. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_engine.py +0 -0
  83. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_engine_fixes.py +0 -0
  84. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_framework_examples.py +0 -0
  85. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_i18n.py +0 -0
  86. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_incremental.py +0 -0
  87. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_link_translate.py +0 -0
  88. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_linked_records.py +0 -0
  89. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_log_and_check.py +0 -0
  90. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_measure_questions.py +0 -0
  91. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_opposed_answers.py +0 -0
  92. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_option_lead.py +0 -0
  93. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_order_detail.py +0 -0
  94. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_profiles.py +0 -0
  95. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_profiles_041.py +0 -0
  96. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_quantifiers_and_identity.py +0 -0
  97. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_questions_not_assertions.py +0 -0
  98. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_references.py +0 -0
  99. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_refusal_friction.py +0 -0
  100. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_refusal_results.py +0 -0
  101. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_refusals.py +0 -0
  102. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_relation_words.py +0 -0
  103. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_report_answers.py +0 -0
  104. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_rerunnable.py +0 -0
  105. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_restates_scale.py +0 -0
  106. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_server_auth.py +0 -0
  107. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_server_hosts.py +0 -0
  108. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_shape_refusals.py +0 -0
  109. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_stats_not_backtests.py +0 -0
  110. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_stats_values.py +0 -0
  111. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_surfaces.py +0 -0
  112. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_telemetry_stream.py +0 -0
  113. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_trust_boundary.py +0 -0
  114. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_ui_app.py +0 -0
  115. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_ui_engine.py +0 -0
  116. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_uncorrected_effect.py +0 -0
  117. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_unnamed_inputs.py +0 -0
  118. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_verify_complete.py +0 -0
  119. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_verify_lifecycle.py +0 -0
  120. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_verify_scope.py +0 -0
  121. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_verify_scoped_views.py +0 -0
  122. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_verify_trust.py +0 -0
  123. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_verify_views.py +0 -0
  124. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_versions_not_figures.py +0 -0
  125. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_vocabulary_friction.py +0 -0
  126. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_what_a_count_is_of.py +0 -0
  127. {hypgraph-0.7.26 → hypgraph-0.7.28}/tests/test_workflows.py +0 -0
@@ -1,5 +1,53 @@
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
+
28
+ ## 0.7.27 — 2026-11-01: words said elsewhere, comparisons the checks didn't know
29
+
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
31
+ (`tests/test_borrowed_words.py`).
32
+
33
+ - **A word said in another clause doesn't make a count fit** (HG-026-01). What a count's key says it counted had to be
34
+ said by the measurement's name or anywhere in the statement, so "Speed was recorded separately; no route is longer
35
+ than 26 km" let a speed count named "routes longer than 26 km" back the length claim, and "Cargo routes were
36
+ inspected; no passenger route is longer …" did the same for a cargo count. The words must now be said where the
37
+ claim states the count's figure (its clause, from the comma before its absolute or extreme). Eight paraphrases over
38
+ other subjects and properties, written after the fix, are tests too: an extra clause never turns a refusal into an
39
+ acceptance, and an honest count backs the claim with or without one.
40
+ - **Any comparison makes an absolute** (HG-026-01, found while reproducing it). "No crate is heavier than 40 kg" and "no
41
+ shift had errors above 5" weren't read as absolutes, so they went in with no count, or with a count of crate prices.
42
+ "No …" before any comparison word is now an absolute; heavier / lighter, warmer / colder, older / younger, taller,
43
+ wider, deeper, louder and stronger are comparisons along their own axes (weight, temperature, age, height, width,
44
+ depth, loudness, strength), and a comparative the checks don't know ("brighter than") is met only by a count that
45
+ says the same word. A three-letter word before a comparison ("bridge age above 60") now names what was counted, and
46
+ "span" is a length, so an honest count of bridge spans backs "no bridge is longer than 60 m".
47
+ - **A mixed "and" / "or" clause has to be split** (HG-026-02). 0.7.26's notes said such a clause is unresolved "until a
48
+ count of the whole condition is bound"; nothing ties a count to the whole condition, so that never happened. The
49
+ refusal now says to split the claim into clauses that each join their comparisons one way.
50
+
3
51
  ## 0.7.26 — 2026-11-01: what a count is of, what its key compared, "and" with "or"
4
52
 
5
53
  Fixes from an independent audit of 0.7.25 (HG-025-01 to -03); each case below failed on 0.7.25 and is now a test
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.26
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.26 (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
@@ -181,6 +181,38 @@ was taken from a changelog without being checked against the code.*
181
181
  > **Where that stands.** #96 is merged with green CI (17 of 17 checks). Kinds and subjects are still read from the
182
182
  > words around a comparison, a heuristic; the typed claim layer the audits recommend is not built. Released as 0.7.26.
183
183
 
184
+ > **Audit of 0.7.26 (2026-11-01), fixed for 0.7.27.** A seventh independent audit named two defects; both reproduced on
185
+ > the PyPI 0.7.26 wheel. A clause that only mentioned speed ("Speed was recorded separately; no route is longer than
186
+ > 26 km") let a speed count named "routes longer than 26 km" back the length claim, and the same for cargo named as
187
+ > passenger routes: the key's words were looked for anywhere in the statement (HG-026-01). Reproducing it found a
188
+ > wider gap: "no crate is heavier than 40 kg" wasn't read as an absolute, so it went in with no count or with a count
189
+ > of crate prices. And the 0.7.26 note that a count of the whole condition settles a mixed "and" / "or" clause promised
190
+ > something nothing implements (HG-026-02). A key's words must now be said where the claim states the count's figure;
191
+ > "no …" before any comparison is an absolute; eight more comparison pairs have axes of their own, and an unknown
192
+ > "-er than" is met only by a count saying the same word; the mixed-clause refusal says to split the claim. Eleven
193
+ > paraphrases written after the first fix found three more gaps (a three-letter subject, "span", a comma-led aside),
194
+ > fixed before merge. Replaying the 1,900 recorded adversarial entries changes none.
195
+ >
196
+ > **Where that stands.** #98 is merged with green CI (17 of 17 checks). This is still word matching; the typed claim
197
+ > layer the audits recommend is not built. Released as 0.7.27.
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
+
184
216
  ## Recommendation: CONDITIONAL GO
185
217
 
186
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.26"
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
@@ -243,7 +243,7 @@ def _bound(s: Snap, meas: list, stmt: str = "") -> tuple[list[tuple[dict, dict]]
243
243
  bad = True
244
244
  # a name that says no comparison ("fitting selections") doesn't say another count; one that does must keep
245
245
  # what the key counted, here or in the statement
246
- unsaid = _unsaid(_subject_of(key), f"{m.get('name') or ''} . {stmt}")
246
+ unsaid = _unsaid(_subject_of(key), f"{m.get('name') or ''} . {_clauses_at(stmt, _count_figures(m, rv))}")
247
247
  if not bad and unsaid and _comparisons_in(str(m.get("name") or "")):
248
248
  problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: result {m['result']} "
249
249
  f"counted '{m['key']}', and neither the name nor the statement says what it "
@@ -256,6 +256,36 @@ def _bound(s: Snap, meas: list, stmt: str = "") -> tuple[list[tuple[dict, dict]]
256
256
  return good, problems
257
257
 
258
258
 
259
+ # where one thing said ends and another begins: a sentence, a semicolon or colon, or a comma before a conjunction
260
+ _SAYING = re.compile(r"[;:!?]|\.(?!\d)|,\s*(?=(?:and|but|while|whereas|although|though|so|yet)\b)|"
261
+ r"\b(?:although|though|whereas)\b", re.I)
262
+
263
+
264
+ def _clauses_at(stmt: str, figs: set[float]) -> str:
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."""
270
+ if not stmt or not figs:
271
+ return stmt
272
+ parts = [p for p in _SAYING.split(stmt) if p and p.strip()]
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
278
+
279
+
280
+ def _from_claim(part: str) -> str:
281
+ """A part from the comma before its absolute or extreme: "Rural sites aside, no urban clinic had waits above 45"
282
+ claims only "no urban clinic had waits above 45"."""
283
+ hit = L.ABSOLUTE.search(part) or L.SUPERLATIVE.search(part)
284
+ if not hit:
285
+ return part
286
+ return part[max((x.end() for x in re.finditer(r",(?!\d)", part[:hit.start()])), default=0):]
287
+
288
+
259
289
  def _axis_words(axes: set[str]) -> str:
260
290
  return " / ".join(sorted("/".join(w for w, (a, _s) in _DIRECTION_OF.items() if a == x and not w.endswith("est"))
261
291
  for x in axes))
@@ -309,13 +339,11 @@ _PROVEN = re.compile(r"^prov(?:es?|en|ed)$|^proof$", re.I)
309
339
 
310
340
  # the figure a count is about: "fitting selections worth more than 93", "routes shorter than 26 km" (or, matching a
311
341
  # figure the claim names, "worth_more_93"). A count of nothing beyond 140 says nothing about 93 (audit HG-020-01/02).
312
- _COUNT_FIGURE = re.compile(r"\b(?:(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
313
- r"slower|larger|smaller)\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+\$?"
314
343
  r"(-?\d[\d,]*(?:\.\d+)?)", re.I)
315
344
  # the figure a name gives before its comparison or with "at least": "routes 26 km or longer", "routes at least 26 km"
316
345
  _COUNT_FIGURE_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
317
- r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:more|less|fewer|greater|higher|lower|better|worse|"
318
- r"shorter|longer|cheaper|faster|slower|larger|smaller)\b", re.I)
346
+ r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + L.CMP_WORDS + r")\b", re.I)
319
347
 
320
348
 
321
349
  def _named_figure(name: str) -> tuple[float, str] | None:
@@ -329,16 +357,22 @@ def _named_figure(name: str) -> tuple[float, str] | None:
329
357
  return None
330
358
 
331
359
 
332
- _KEY_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
333
- r"slower|larger|smaller|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+"
334
362
  r"(\d+(?:\.\d+)?)$", re.I)
335
363
  # the figure an absolute names in its own clause: "nothing worth more than 93 fits", "no route is shorter than 26 km"
336
- _CLAIM_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
337
- r"slower|larger|smaller)\s+than\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\b(?:above|below|beyond|"
338
- 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)
339
374
  _CLAIM_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
340
- r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:more|less|fewer|greater|higher|lower|better|worse|"
341
- r"shorter|longer|cheaper|faster|slower|larger|smaller)\b", re.I)
375
+ r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + L.CMP_WORDS + r")\b", re.I)
342
376
  _SUBCLAUSE = re.compile(r",(?!\d)|[;:!?()]|\.(?!\d)|\b(?:and|but|while|whereas|although|so)\b", re.I)
343
377
 
344
378
 
@@ -374,7 +408,8 @@ def _count_figures(m: dict, rv: dict, figs: set[float] = frozenset()) -> set[flo
374
408
  # routes longer than 26 km says nothing against "the shortest is 26 km" (audit HG-022-01), and none shorter than 26
375
409
  # doesn't back "nothing is longer than 26" (HG-022-02).
376
410
  _DIRECTION_WORDS = {
377
- "": {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 "
378
413
  "greatest biggest maximum max top",
379
414
  -1: "less fewer lower smaller below under least lowest smallest fewest minimum min"},
380
415
  "long": {1: "longer longest", -1: "shorter shortest"},
@@ -382,6 +417,14 @@ _DIRECTION_WORDS = {
382
417
  "fast": {1: "faster fastest", -1: "slower slowest"},
383
418
  "cost": {1: "dearer dearest", -1: "cheaper cheapest"},
384
419
  "time": {1: "later latest", -1: "earlier earliest"},
420
+ "heavy": {1: "heavier heaviest", -1: "lighter lightest"},
421
+ "warm": {1: "warmer warmest hotter hottest", -1: "colder coldest cooler coolest"},
422
+ "old": {1: "older oldest", -1: "younger youngest newer newest"},
423
+ "tall": {1: "taller tallest"},
424
+ "wide": {1: "wider widest", -1: "narrower narrowest"},
425
+ "deep": {1: "deeper deepest", -1: "shallower shallowest"},
426
+ "loud": {1: "louder loudest", -1: "quieter quietest"},
427
+ "strong": {1: "stronger strongest", -1: "weaker weakest"},
385
428
  }
386
429
  _DIRECTION_OF = {w: (axis, sign) for axis, signs in _DIRECTION_WORDS.items() for sign, ws in signs.items()
387
430
  for w in ws.split()}
@@ -389,8 +432,7 @@ _DIRECTION_OF = {w: (axis, sign) for axis, signs in _DIRECTION_WORDS.items() for
389
432
  # not strict, so a route of exactly 26 lies inside them and outside "longer than" (audit HG-023-02). Six operators;
390
433
  # "=" and "!=" point nowhere.
391
434
  _OPS = {">": (1, True), ">=": (1, False), "<": (-1, True), "<=": (-1, False), "=": None, "!=": None}
392
- _CMP = r"(more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|dearer|faster|slower|larger|" \
393
- r"smaller|bigger|earlier|later)"
435
+ _CMP = r"(" + L.CMP_WORDS + r")"
394
436
  _NOT_STRICT = re.compile(r"\bat\s+(least|most)\b|\b(?:not|no)\s+" + _CMP + r"\s+than\b|\bor\s+" + _CMP + r"\b", re.I)
395
437
 
396
438
 
@@ -408,7 +450,13 @@ def _comparisons_in(text: str) -> set[tuple[str, int, bool]]:
408
450
  else:
409
451
  out.add((*_DIRECTION_OF[h.group(3)], False))
410
452
  rest = _NOT_STRICT.sub(" ", text)
411
- return out | {(*_DIRECTION_OF[w], True) for w in re.findall(r"[a-z]+", rest) if w in _DIRECTION_OF}
453
+ # a comparative the checks don't know ("brighter than") is an axis of its own: only a count that says the same word
454
+ # meets it, so nothing else can back a claim made with it (audit HG-026-01)
455
+ unknown = {(w, 1, True) for w in _UNKNOWN_CMP.findall(rest) if w not in _DIRECTION_OF}
456
+ return out | unknown | {(*_DIRECTION_OF[w], True) for w in re.findall(r"[a-z]+", rest) if w in _DIRECTION_OF}
457
+
458
+
459
+ _UNKNOWN_CMP = re.compile(r"\b(?!(?:" + L.NOT_CMP + r")\b)([a-z]{3,}er)\s+than\b")
412
460
 
413
461
 
414
462
  def _directions(text: str) -> set[tuple[str, int]]:
@@ -458,14 +506,18 @@ def _count_directions(m: dict, rv: dict) -> set[tuple[str, int]]:
458
506
  _ALL = re.compile(r"^(?:always|all|every|universally|invariably|consistently|without exception|100\s*%)$", re.I)
459
507
 
460
508
 
461
- 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]]:
462
510
  """(figure, comparison) for each comparison against a figure in a claim's clause: "longer than 26 km", "at least
463
- 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."""
464
513
  out = []
465
- 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:
466
516
  neg = re.search(r"\b(?:not|no)\s+$", clause[:h.start()], re.I) # "no more than 93" is at most 93
467
- num = (h.group(1) or h.group(2)).replace(",", "")
468
- 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}]
469
521
  for h in _CLAIM_BOUNDARY.finditer(clause):
470
522
  num = (h.group(1) or h.group(2)).replace(",", "")
471
523
  out += [(float(num), c) for c in _comparisons_in(h.group(0)) or {None}]
@@ -476,8 +528,8 @@ def _clause_comparisons(clause: str) -> list[tuple[float, tuple[str, int, bool]
476
528
  MIXED = (frozenset(), frozenset({("mixed", 0, False)}))
477
529
 
478
530
  # "and" or "or" before a comparison with no subject of its own: "shorter than 20 km and longer than 30 km"
479
- _JOINED = re.compile(r"\s+(?:also\s+)?(?:(?:not|no)\s+)?(?:" + _CMP + r"\s+than\b|at\s+(?:least|most)\b|above\b|below\b|"
480
- 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)
481
533
 
482
534
 
483
535
  def _clause_end(stmt: str, start: int) -> int:
@@ -510,7 +562,10 @@ def _target_groups(stmt: str, w: str) -> list[list[tuple[frozenset, frozenset]]]
510
562
  for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
511
563
  clause = stmt[m.end():_clause_end(stmt, m.end())]
512
564
  targets = [(frozenset({fig}), frozenset() if c is None else frozenset({_negated(c) if every else c}))
513
- 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))]
514
569
  ands = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\band\b", clause, re.I))
515
570
  ors = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\bor\b", clause, re.I))
516
571
  if len(targets) > 1 and ands and ors:
@@ -582,12 +637,23 @@ _COUNTING = set("count counts counted counting number numbers total totals tally
582
637
  _HOW_MANY = set("nothing none never nobody every always ever each whole entire".split())
583
638
 
584
639
 
640
+ # three-letter words that say nothing about what was counted
641
+ _SHORT_SKIP = set("has had did got get let may can one two six ten few new old top max min own via nor yet per".split())
642
+
643
+
644
+ def _stems(text: str) -> set[str]:
645
+ """``L.content_words`` and the three-letter words it leaves out, which can name what was counted: a count of
646
+ "bridge age above 60" is of ages, and "age" must be said for it to back a claim (audit HG-026-01)."""
647
+ return L.content_words(text) | {w for w in re.findall(r"[a-z]+", (text or "").lower())
648
+ if len(w) == 3 and w not in L.STOP and w not in _SHORT_SKIP}
649
+
650
+
585
651
  def _modifiers(words: list[str], i: int) -> set[str]:
586
652
  """The content words right before ``words[i]``, up to a determiner, number, preposition or comparison: "no passenger
587
653
  route" gives {passeng}, "the 3 cargo routes" {cargo}, "the shortest route" nothing."""
588
654
  out: set[str] = set()
589
655
  for w in reversed(words[:i]):
590
- cw = L.content_words(w)
656
+ cw = _stems(w)
591
657
  if not cw or w in _DETERMINERS or w in _PREPOSITIONS or w in _DIRECTION_OF or w in _COUNTING | _HOW_MANY:
592
658
  break
593
659
  out |= cw
@@ -616,7 +682,7 @@ def _subject_parts(text: str) -> tuple[set[str], set[str]]:
616
682
  while i > 1 and words[i - 1] in _MEASURED_BY: # "selections worth more": the head is selections
617
683
  whole -= L.content_words(words[i - 1])
618
684
  i -= 1
619
- head = L.content_words(words[i - 1]) & whole
685
+ head = _stems(words[i - 1]) & whole
620
686
  return head, whole - head
621
687
 
622
688
 
@@ -626,15 +692,18 @@ _MEASURED_BY = set("worth valued priced weighing costing lasting taking".split()
626
692
 
627
693
  _COPULAS = set("is are was were be been being".split())
628
694
  # the property a comparison word compares: a count of "route length above 26" is said by "no route is longer than 26"
629
- _PROPERTY_AXIS = {s: axis for axis, ws in {"long": "length distance", "fast": "speed velocity pace",
630
- "cost": "cost price", "time": "date time"}.items()
631
- for w in ws.split() for s in L.content_words(w)}
695
+ _PROPERTY_AXIS = {s: axis for axis, ws in {"long": "length distance span", "fast": "speed velocity pace",
696
+ "cost": "cost price", "time": "date time", "heavy": "weight mass",
697
+ "warm": "temperature heat", "old": "age", "tall": "height",
698
+ "wide": "width breadth", "deep": "depth", "loud": "loudness",
699
+ "strong": "strength"}.items()
700
+ for w in ws.split() for s in _stems(w)}
632
701
 
633
702
 
634
703
  def _unsaid(stems: set[str], text: str) -> set[str]:
635
704
  """``stems`` that ``text`` doesn't say: neither as words nor, for a property, by comparing along its axis."""
636
705
  axes = {c[0] for c in _comparisons_in(text) if c[0]}
637
- return {s for s in stems - L.content_words(text) if _PROPERTY_AXIS.get(s) not in axes}
706
+ return {s for s in stems - _stems(text) if _PROPERTY_AXIS.get(s) not in axes}
638
707
 
639
708
 
640
709
  def _subjects_at(stmt: str, axes: set[str]) -> set[str]:
@@ -719,8 +788,16 @@ def _pinned(m: dict, rv: dict, figs: set[float]) -> bool:
719
788
 
720
789
  def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
721
790
  """Every absolute in the statement, and every extreme, checked on its own: a true "nothing worth more than 140
722
- fits" used to end the check, and "the highest is 93" beside it went in unexamined (audit HG-021-02)."""
723
- 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)]
724
801
  if any(str(m.get("key") or "").lower() in ("gap", "bound") for m, _rv in bound):
725
802
  words = [w for w in words if not _PROVEN.match(w)] # an optimum with its bound (gap 0) is proven
726
803
  words = [w for w in words if not _names_counted_set(stmt, w, bound)]
@@ -733,9 +810,10 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
733
810
  seen.add((tuple(group), _ALL.match(w) is None))
734
811
  if group[0] is MIXED:
735
812
  p = Problem("ABSOLUTE_WITHOUT_COUNT", f"'{w}' rules on comparisons joined by both 'and' and 'or': "
736
- "counts of each comparison on its own can't settle that either way. Count what meets the "
737
- "whole condition, or split it into claims that each join their comparisons one way",
738
- fix="count what meets the whole condition, or split the claim", words=words)
813
+ "counts of each comparison on its own can't settle that either way, and no count can be "
814
+ "tied to the whole condition. Split it into claims that each join their comparisons one "
815
+ "way (audit HG-026-02)",
816
+ fix="split the claim into clauses that each join their comparisons one way", words=words)
739
817
  if p.text() not in {q.text() for q in out}:
740
818
  out.append(p)
741
819
  continue
@@ -748,6 +826,10 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
748
826
  "on its own can't show that something meets both. Back it with a count of either one where k is "
749
827
  "0, or count both together", fix="count what meets both comparisons together", words=words)]]
750
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}]
751
833
  if not any(p.code == "COUNT_CONTRADICTS" for p in out):
752
834
  out += _superlative_contradicted(stmt, bound)
753
835
  return out
@@ -860,8 +942,7 @@ def _denied(after: str) -> bool:
860
942
 
861
943
  # a count named for what lies beyond a figure ("fitting selections worth more", "shorter routes connecting") is about
862
944
  # any extreme the statement claims, whatever words the statement uses for it ("the highest total value is 99")
863
- _BEYOND = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|slower|"
864
- r"larger|smaller|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)
865
946
 
866
947
 
867
948
  def _extremes(stmt: str) -> list[str]:
@@ -1344,9 +1425,19 @@ def _p_values(s: Snap, bound: list, support: list[str]) -> list[float]:
1344
1425
  return ps
1345
1426
 
1346
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
+
1347
1437
  def _comparisons(s: Snap, stmt: str, bound: list, support: list[str]) -> list[Problem]:
1348
1438
  out = []
1349
1439
  comp = L.asserted(L.COMPARATIVE, stmt)
1440
+ comp = [w for w in comp if w not in _thresholds(stmt, comp)]
1350
1441
  tested = any(isinstance(rv.get("p"), (int, float)) or rv.get("interval") for _m, rv in bound)
1351
1442
  if comp and len([1 for _m, rv in bound if _primary(rv) is not None]) < 2 and not tested \
1352
1443
  and not _measured_claim(s, support):
@@ -14,6 +14,17 @@ from __future__ import annotations
14
14
 
15
15
  import re
16
16
 
17
+ # every comparative the checks know, and the "-er than" words that aren't one ("other than", "rather than")
18
+ CMP_WORDS = ("longer|shorter|more|less|fewer|greater|higher|lower|larger|smaller|bigger|better|worse|faster|slower|"
19
+ "cheaper|dearer|earlier|later|heavier|lighter|warmer|hotter|colder|cooler|older|younger|newer|taller|wider|"
20
+ "narrower|deeper|shallower|louder|quieter|stronger|weaker")
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)?"
27
+
17
28
  ABSOLUTE = re.compile(
18
29
  r"\b(never|always|all|every|none|nothing|no one|nobody|impossible|cannot|can't|proves?|proven|proof|certainly|"
19
30
  r"certain|guaranteed?|guarantees|eliminat(?:es|ed|ing)|entirely|completely|totally|universally|invariably|"
@@ -22,14 +33,16 @@ ABSOLUTE = re.compile(
22
33
  # "no route is longer than 26 km": a universal negative comparison, the same claim as "nothing is longer"; only
23
34
  # where "no" opens its clause ("routes that visit no town twice" is not one); "no route is 26 km or longer" and
24
35
  # "no route is at least 26 km" too
25
- r"|(?<![a-z] )no(?=\s+(?:[a-z]+\s+){1,3}?(?:is|are|was|were)\s+(?:any\s+)?(?:at\s+(?:least|most)\s|"
26
- r"\$?\d[\d,.]*(?:\s+[a-z%]+){0,2}\s+or\s+(?:longer|shorter|more|less|fewer|greater|higher|lower|larger|smaller|"
27
- r"better|worse|faster|slower|cheaper)\b|(?:longer|shorter|more|less|fewer|greater|higher|lower|larger|smaller|"
28
- r"better|worse|faster|slower|cheaper|above|below|over|under)\b)))\b|(?<![\d.])100\s*%", re.I)
36
+ # Any comparison word, and any verb before it: "no crate is heavier than 40 kg", "no shift had errors above 5",
37
+ # "no crate weighs more than 40 kg" (audit HG-026-01: these went unread, so any count backed them)
38
+ r"|(?<![a-z] )no(?=\s+(?:[a-z]+\s+){1,4}?(?:(?:is|are|was|were)\s+(?:any\s+)?)?(?:at\s+(?:least|most)\s|"
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)
29
41
  # a superlative names an extreme ("the highest value is 93"): a count of anything beyond it contradicts it
30
42
  SUPERLATIVE = re.compile(
31
43
  r"\b(highest|lowest|largest|smallest|shortest|longest|greatest|fewest|best|worst|maximum|minimum|optimal|"
32
- r"optimum|cheapest|fastest|slowest|biggest|earliest|latest)\b", re.I)
44
+ r"optimum|cheapest|fastest|slowest|biggest|earliest|latest|heaviest|lightest|warmest|hottest|coldest|coolest|"
45
+ r"oldest|youngest|newest|tallest|widest|narrowest|deepest|shallowest|loudest|quietest|strongest|weakest)\b", re.I)
33
46
  COMPARATIVE = re.compile(
34
47
  r"\b(better|worse|beats?|beat|outperform(?:s|ed|ing)?|underperform(?:s|ed|ing)?|improv(?:es|ed|ement|ing)|"
35
48
  r"superior|inferior|exceeds?|exceeded|higher than|lower than|more than|fewer than|less than|faster|slower|"
@@ -0,0 +1,130 @@
1
+ """A word said somewhere else in the statement, and a comparison the checks didn't know (audit of 0.7.26, HG-026-01
2
+ and -02).
3
+
4
+ A count's key says what was counted; the claimant's name for it must keep that, here or where the claim states the
5
+ count's figure. "Speed was recorded separately; no route is longer than 26 km" says nothing about speed where it says
6
+ 26 km, so a speed count named "routes longer than 26 km" can't borrow the word. The invariant: an extra clause never
7
+ turns a refusal into an acceptance, and an honest count backs the claim with or without it. "No crate is heavier than
8
+ 40 kg" is an absolute like "no route is longer than 26 km", and a comparison the checks don't know ("brighter than")
9
+ is met only by a count that says the same word."""
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=()):
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=3):
34
+ return {"k": k, "n": n, "figure": figure, "op": op}
35
+
36
+
37
+ def M(key, name, k, n=3):
38
+ return {"name": name, "key": key, "k": k, "n": n}
39
+
40
+
41
+ # (frozen values, the relabelled count, the claim, the claim with the borrowed word said elsewhere)
42
+ LAUNDER = {
43
+ "speed as length": ({"route_speed_above_26": t(0, 26)}, M("route_speed_above_26", "routes longer than 26 km", 0),
44
+ "No route is longer than 26 km among the 3 routes.",
45
+ ["Speed was recorded separately; no route is longer than 26 km among the 3 routes."]),
46
+ "cargo as passenger": ({"cargo_route_longer_26": t(0, 26), "passenger_route_longer_26": t(1, 26)},
47
+ M("cargo_route_longer_26", "passenger route longer than 26 km", 0),
48
+ "No passenger route is longer than 26 km among the 3 routes.",
49
+ ["Cargo routes were inspected; no passenger route is longer than 26 km among the 3 "
50
+ "routes."]),
51
+ "night as day": ({"night_shift_errors_above_5": t(0, 5), "day_shift_errors_above_5": t(3, 5)},
52
+ M("night_shift_errors_above_5", "day shift errors above 5", 0),
53
+ "No day shift had errors above 5 among the 3 shifts.",
54
+ ["The night shift was audited separately; no day shift had errors above 5 among the 3 shifts."]),
55
+ "price as weight": ({"crate_price_above_40": t(0, 40)}, M("crate_price_above_40", "crates heavier than 40 kg", 0),
56
+ "No crate is heavier than 40 kg among the 3 crates.",
57
+ ["Prices were logged too; no crate is heavier than 40 kg among the 3 crates."]),
58
+ "frozen as fresh": ({"frozen_sample_warmer_8": t(0, 8), "fresh_sample_warmer_8": t(2, 8)},
59
+ M("frozen_sample_warmer_8", "fresh sample warmer than 8 C", 0),
60
+ "No fresh sample is warmer than 8 C among the 3 samples.",
61
+ ["Frozen samples were checked as well; no fresh sample is warmer than 8 C among the 3 "
62
+ "samples."]),
63
+ "age as span": ({"bridge_age_above_60": t(0, 60)}, M("bridge_age_above_60", "bridges longer than 60 m", 0),
64
+ "No bridge is longer than 60 m among the 3 bridges.",
65
+ ["Ages were taken from the register. No bridge is longer than 60 m among the 3 bridges.",
66
+ "Although age was recorded, no bridge is longer than 60 m among the 3 bridges.",
67
+ "We noted each bridge's age, and no bridge is longer than 60 m among the 3 bridges."]),
68
+ "rural as urban": ({"rural_clinic_waits_above_45": t(0, 45), "urban_clinic_waits_above_45": t(3, 45)},
69
+ M("rural_clinic_waits_above_45", "urban clinic waits above 45 minutes", 0),
70
+ "No urban clinic had waits above 45 minutes among the 3 clinics.",
71
+ ["Rural clinics were visited first; no urban clinic had waits above 45 minutes among the 3 "
72
+ "clinics.",
73
+ "Rural sites aside, no urban clinic had waits above 45 minutes among the 3 clinics."]),
74
+ "unknown comparison": ({"lamp_wattage_above_40": t(0, 40)}, M("lamp_wattage_above_40", "lamps brighter than 40 lux",
75
+ 0),
76
+ "No lamp is brighter than 40 lux among the 3 lamps.",
77
+ ["Wattage was read off each label. No lamp is brighter than 40 lux among the 3 lamps."]),
78
+ }
79
+
80
+
81
+ @pytest.mark.parametrize("case", list(LAUNDER))
82
+ def test_a_word_said_elsewhere_does_not_launder_a_count(case):
83
+ """HG-026-01: the missing word was looked for anywhere in the statement, so a clause that only mentioned speed (or
84
+ cargo, or the night shift) let a count of it back a claim about something else."""
85
+ values, m, claim, padded = LAUNDER[case]
86
+ assert "MEASUREMENT_MISMATCH" in outcome(values, claim, [m])
87
+ for s in padded:
88
+ assert "MEASUREMENT_MISMATCH" in outcome(values, s, [m]), s
89
+
90
+
91
+ HONEST = [
92
+ ({"passenger_route_longer_26": t(0, 26)}, M("passenger_route_longer_26", "passenger routes longer than 26 km", 0),
93
+ ["No passenger route is longer than 26 km among the 3 routes.",
94
+ "Cargo routes were inspected; no passenger route is longer than 26 km among the 3 routes."]),
95
+ ({"bridge_span_above_60": t(0, 60)}, M("bridge_span_above_60", "bridge spans above 60 m", 0),
96
+ ["No bridge is longer than 60 m among the 3 bridges.",
97
+ "Ages were taken from the register. No bridge is longer than 60 m among the 3 bridges."]),
98
+ ({"well_temperature_above_30": t(0, 30)}, M("well_temperature_above_30", "well temperature above 30 C", 0),
99
+ ["No well is warmer than 30 C among the 3 wells.",
100
+ "Depth was logged: no well is warmer than 30 C among the 3 wells."]),
101
+ ({"crate_weight_above_40": t(0, 40)}, M("crate_weight_above_40", "crates heavier than 40 kg", 0),
102
+ ["No crate is heavier than 40 kg among the 3 crates.",
103
+ "Prices were logged too; no crate is heavier than 40 kg among the 3 crates."]),
104
+ ({"lamps_brighter_than_40": t(0, 40)}, M("lamps_brighter_than_40", "lamps brighter than 40 lux", 0),
105
+ ["No lamp is brighter than 40 lux among the 3 lamps."]),
106
+ ]
107
+
108
+
109
+ @pytest.mark.parametrize("i", range(len(HONEST)))
110
+ def test_an_honest_count_backs_the_claim_with_or_without_another_clause(i):
111
+ values, m, statements = HONEST[i]
112
+ for s in statements:
113
+ assert outcome(values, s, [m]) == [], s
114
+
115
+
116
+ @pytest.mark.parametrize("s", ["No crate is heavier than 40 kg among the 3 crates.",
117
+ "No lamp is brighter than 40 lux among the 3 lamps.",
118
+ "No shift had errors above 5 among the 3 shifts."])
119
+ def test_an_absolute_with_any_comparison_needs_a_count(s):
120
+ """These weren't read as absolutes, so the record took them with no count at all, or with a count of anything."""
121
+ assert "ABSOLUTE_WITHOUT_COUNT" in outcome({"other": t(0, 1)}, s)
122
+
123
+
124
+ def test_a_count_cannot_be_tied_to_a_mixed_condition():
125
+ """HG-026-02: 0.7.26's notes said a count of the whole condition would settle a mixed "and" / "or" clause; nothing
126
+ ties a count to it, so the refusal says to split the claim."""
127
+ values = {"longer_10_and_shorter_30_or_shorter_6": t(3, 10), "longer_10": t(2, 10)}
128
+ codes = outcome(values, "Every route is longer than 10 km and shorter than 30 km or shorter than 6 km among the 3 "
129
+ "routes.", [M("longer_10", "routes longer than 10 km", 2)])
130
+ assert "ABSOLUTE_WITHOUT_COUNT" in codes