hypgraph 0.7.23__tar.gz → 0.7.24__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 (123) hide show
  1. {hypgraph-0.7.23 → hypgraph-0.7.24}/CHANGELOG.md +35 -0
  2. {hypgraph-0.7.23 → hypgraph-0.7.24}/PKG-INFO +1 -1
  3. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/RELEASE-READINESS.md +18 -0
  5. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/checks.py +254 -118
  7. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/language.py +6 -3
  8. hypgraph-0.7.24/tests/test_quantifiers_and_identity.py +207 -0
  9. {hypgraph-0.7.23 → hypgraph-0.7.24}/.gitignore +0 -0
  10. {hypgraph-0.7.23 → hypgraph-0.7.24}/AGENTS.md +0 -0
  11. {hypgraph-0.7.23 → hypgraph-0.7.24}/CITATION.cff +0 -0
  12. {hypgraph-0.7.23 → hypgraph-0.7.24}/DISCLAIMER.md +0 -0
  13. {hypgraph-0.7.23 → hypgraph-0.7.24}/LICENSE +0 -0
  14. {hypgraph-0.7.23 → hypgraph-0.7.24}/NOTICE +0 -0
  15. {hypgraph-0.7.23 → hypgraph-0.7.24}/README.md +0 -0
  16. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  17. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  18. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/profile-workflows-plan.md +0 -0
  19. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/review-2026-10-24.md +0 -0
  20. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/site-plan.md +0 -0
  21. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/spec.md +0 -0
  22. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/verification.md +0 -0
  24. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/README.md +0 -0
  25. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/knowledge.md +0 -0
  26. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/optimization.md +0 -0
  27. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/scientific.md +0 -0
  28. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/software.md +0 -0
  29. {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/trading.md +0 -0
  30. {hypgraph-0.7.23 → hypgraph-0.7.24}/examples/frameworks/README.md +0 -0
  31. {hypgraph-0.7.23 → hypgraph-0.7.24}/pyproject.toml +0 -0
  32. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/blind.py +0 -0
  34. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/bundle.py +0 -0
  35. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/demos/linked.py +0 -0
  40. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/mcp.py +0 -0
  43. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/plan.py +0 -0
  44. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/policy.py +0 -0
  45. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/profiles.py +0 -0
  46. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/references.py +0 -0
  47. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/refusals.py +0 -0
  48. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/report_check.py +0 -0
  49. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/schema.py +0 -0
  50. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/server.py +0 -0
  51. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/skill/SKILL.md +0 -0
  52. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/stats.py +0 -0
  53. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/store.py +0 -0
  54. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/__init__.py +0 -0
  55. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/atlas.css +0 -0
  56. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/atlas.js +0 -0
  57. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.css +0 -0
  58. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  59. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.js +0 -0
  60. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.svg +0 -0
  61. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/index.html +0 -0
  62. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  63. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  64. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  65. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  66. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  67. {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/workflow.py +0 -0
  68. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_accept_work_kind.py +0 -0
  69. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_anchoring.py +0 -0
  70. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_blind.py +0 -0
  71. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_blind_oracles.py +0 -0
  72. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_bound_support.py +0 -0
  73. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_bundle_telemetry.py +0 -0
  74. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_cancel_releases_lease.py +0 -0
  75. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_cited_and_failed_support.py +0 -0
  76. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_contradictions.py +0 -0
  77. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_coord.py +0 -0
  78. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_d10.py +0 -0
  79. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_direction_and_units.py +0 -0
  80. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_engine.py +0 -0
  81. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_engine_fixes.py +0 -0
  82. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_framework_examples.py +0 -0
  83. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_i18n.py +0 -0
  84. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_incremental.py +0 -0
  85. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_link_translate.py +0 -0
  86. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_linked_records.py +0 -0
  87. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_log_and_check.py +0 -0
  88. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_measure_questions.py +0 -0
  89. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_opposed_answers.py +0 -0
  90. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_option_lead.py +0 -0
  91. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_order_detail.py +0 -0
  92. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_profiles.py +0 -0
  93. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_profiles_041.py +0 -0
  94. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_questions_not_assertions.py +0 -0
  95. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_references.py +0 -0
  96. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_refusal_friction.py +0 -0
  97. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_refusal_results.py +0 -0
  98. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_refusals.py +0 -0
  99. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_relation_words.py +0 -0
  100. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_report_answers.py +0 -0
  101. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_rerunnable.py +0 -0
  102. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_restates_scale.py +0 -0
  103. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_server_auth.py +0 -0
  104. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_server_hosts.py +0 -0
  105. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_shape_refusals.py +0 -0
  106. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_stats_not_backtests.py +0 -0
  107. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_stats_values.py +0 -0
  108. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_surfaces.py +0 -0
  109. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_telemetry_stream.py +0 -0
  110. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_trust_boundary.py +0 -0
  111. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_ui_app.py +0 -0
  112. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_ui_engine.py +0 -0
  113. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_uncorrected_effect.py +0 -0
  114. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_unnamed_inputs.py +0 -0
  115. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_complete.py +0 -0
  116. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_lifecycle.py +0 -0
  117. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_scope.py +0 -0
  118. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_scoped_views.py +0 -0
  119. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_trust.py +0 -0
  120. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_views.py +0 -0
  121. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_versions_not_figures.py +0 -0
  122. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_vocabulary_friction.py +0 -0
  123. {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_workflows.py +0 -0
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.24 — 2026-11-01: what a quantifier needs, where a comparison's boundary falls, one check per extreme
4
+
5
+ Fixes from an independent audit of 0.7.23 (HG-023-01 to -05); each case below failed on 0.7.23 and is now a test.
6
+
7
+ - **"Nothing" needs none, "every" needs all** (HG-023-01). Any count of none or all backed an absolute, so "nothing
8
+ is longer than 26 km" went in with all 3 routes longer, and "every route is longer than 26 km" with none. When a
9
+ count's comparison is known, the claim now needs the extreme its quantifier names; the other extreme shows it false
10
+ (`COUNT_CONTRADICTS`). A count of what lies outside the claim's exceptions is read as its complement: none of 3
11
+ longer than 26 is 3 of 3 at 26 or shorter. "Every" in a clause that states its own count ("a count of every route
12
+ finds 1 of 76 shorter") names the set counted and is not read as a quantifier. A count whose comparison can't be
13
+ told ("failures") is read as before.
14
+ - **"At least" is not "longer than"** (HG-023-02). Comparisons keep their boundary: `op` takes `<`, `<=`, `=`, `!=`,
15
+ `>=`, `>`, and names say "at least", "at most", "26 km or longer", "not / no shorter than", "no more than". A count
16
+ refutes a claim only if everything it found is an exception, and backs it only if it covers every exception: two
17
+ routes at least 26 km neither refute nor back "no route is longer than 26 km" (the routes of exactly 26 lie between),
18
+ and none longer than 26 doesn't back "no route is 26 km or longer". `=` and `!=` counts don't bear on a comparison.
19
+ "At least" used to be read as pointing down and "at most" up. "No route is 26 km or longer" and "no route is at
20
+ least 26 km" are absolutes too.
21
+ - **Two extremes are two claims** (HG-023-03). "The shortest route is 26 km; the longest route is 35 km" pooled both
22
+ figures and both directions, and the count of 2 longer than 26 refused the true sentence. Each extreme, and each
23
+ thing an absolute rules out, is now checked with its own figure and direction; the refusal names the extreme it
24
+ found false.
25
+ - **What "of" and "for" name says which thing** (HG-023-04). "Weights of cargo" and "weights of passengers" both read
26
+ as bare "weights", and "shipping cargo weights" met "shipping passenger weights" on one shared word. The object of
27
+ "of" / "for" now counts, and two subjects that each say something the other doesn't are left apart: unresolved, not
28
+ a contradiction. One that only adds to the other ("the recorded weights of cargo") is the same thing.
29
+ - **A contrast keeps the unit it asserts** (HG-023-05). "Not in pounds but in kilograms" lost its kilograms, and
30
+ "kilograms rather than pounds" kept both, so a claim of pounds citing either went in. A clause with no subject or
31
+ verb of its own takes the subject its sentence named last ("… but always in kilograms"); one that names its own
32
+ ("and passenger weights in pounds") doesn't. What follows "rather than" / "instead of", up to its unit, is set aside.
33
+ - Limits: subjects, units of record, quantifiers and comparisons are still read from words, not typed (only a count's
34
+ `figure` and `op` are); a missed contradiction is preferred to a false one, so subjects left apart are not
35
+ compared; conversions aren't checked; and the replay of earlier recorded entries changes nothing, which shows no
36
+ regression but none of those entries tries these cases.
37
+
3
38
  ## 0.7.23 — 2026-11-01: a count points one way, "no route is longer", what a unit is said of
4
39
 
5
40
  Fixes from an independent audit of 0.7.22 (HG-022-01 to -05); each case below failed on 0.7.22 and is now a test.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.23
3
+ Version: 0.7.24
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.23 (2026-11-01)
112
+ ## Status at 0.7.24 (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
@@ -131,6 +131,24 @@ was taken from a changelog without being checked against the code.*
131
131
  > which unit a record uses are still read from words, not typed; a fresh independent audit should precede any claim
132
132
  > that 0.7.23 resists these attack classes. Released as 0.7.23.
133
133
 
134
+ > **Audit of 0.7.23 (2026-11-01), fixed for 0.7.24.** A fourth independent audit, a code review without running the
135
+ > wheel, named five candidate defects; each reproduced on the PyPI 0.7.23 wheel, plus two of the same kind. A count of
136
+ > none or all backed any absolute, so "nothing is longer than 26 km" went in with all routes longer and "every route
137
+ > is longer" with none (HG-023-01). Comparisons lost their boundary, so a count at least 26 refuted the true "no route
138
+ > is longer than 26" and "at least" was read as pointing down (HG-023-02). Two extremes in one sentence were pooled
139
+ > and a true one refused (HG-023-03). "Weights of cargo" and "weights of passengers" read as one thing (HG-023-04),
140
+ > and "not in pounds but in kilograms" lost its kilograms (HG-023-05). A claim now needs the extreme its quantifier
141
+ > names; comparisons keep their boundary across six operators; each extreme is checked on its own; the object of "of"
142
+ > / "for" says which thing, and subjects that each say something the other doesn't are left apart, unresolved; a
143
+ > contrast keeps its unit. Each case is a regression test written to fail on 0.7.23. Replaying the 1,900 recorded
144
+ > adversarial entries changes nothing against 0.7.23 (a first version over-read "a count of every route" and named
145
+ > one honest party wrongly; caught by the replay, fixed and tested), but none of those entries tries these cases.
146
+ >
147
+ > **Where that stands.** #89 is merged with green CI (17 of 17 checks); from the 0.7.24 wheel in a fresh venv the
148
+ > suite is 549 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. The audit recommends a typed
149
+ > claim layer (subject, property, comparator, threshold, unit, quantifier) so that strong verdicts rest on typed fields
150
+ > rather than prose; that is not built. Released as 0.7.24.
151
+
134
152
  ## Recommendation: CONDITIONAL GO
135
153
 
136
154
  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.23"
19
+ __version__ = "0.7.24"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -207,14 +207,14 @@ def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]
207
207
  problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name') or i + 1}: says "
208
208
  f"{k}={m[k]} but result {m['result']} has {rv[k]}"))
209
209
  bad = True
210
- said, f = _COUNT_FIGURE.search(str(m.get("name") or "")), stated_figure(rv)
211
- if not bad and said and f is not None and float(said.group(1).replace(",", "")) != f:
210
+ said, f = _named_figure(str(m.get("name") or "")), stated_figure(rv)
211
+ if not bad and said and f is not None and said[0] != f:
212
212
  problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: its name says "
213
- f"{said.group(0).strip()}, but result {m['result']} counted '{m['key']}' against "
213
+ f"{said[1]}, but result {m['result']} counted '{m['key']}' against "
214
214
  f"{_fmt(f)}", fix=f"name the count after {_fmt(f)}, the figure it was made against"))
215
215
  bad = True
216
216
  op, named = stated_op(rv), _directions(str(m.get("name") or ""))
217
- if not bad and op and any(_opposed(d, ("", _OPS[op])) for d in named):
217
+ if not bad and _OPS.get(op) and any(_opposed(d, ("", _OPS[op][0])) for d in named):
218
218
  problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: its name points the other "
219
219
  f"way from what result {m['result']} counted for '{m['key']}' ({op})",
220
220
  fix=f"name the count after the comparison it made ({op})"))
@@ -270,6 +270,23 @@ _PROVEN = re.compile(r"^prov(?:es?|en|ed)$|^proof$", re.I)
270
270
  _COUNT_FIGURE = re.compile(r"\b(?:(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
271
271
  r"slower|larger|smaller)\s+than|above|below|over|under|beyond|exceed\w*|beat\w*)\s+\$?"
272
272
  r"(-?\d[\d,]*(?:\.\d+)?)", re.I)
273
+ # the figure a name gives before its comparison or with "at least": "routes 26 km or longer", "routes at least 26 km"
274
+ _COUNT_FIGURE_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
275
+ r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:more|less|fewer|greater|higher|lower|better|worse|"
276
+ r"shorter|longer|cheaper|faster|slower|larger|smaller)\b", re.I)
277
+
278
+
279
+ def _named_figure(name: str) -> tuple[float, str] | None:
280
+ """The figure a count's name says it is about, and the words that say it."""
281
+ hit = _COUNT_FIGURE.search(name or "")
282
+ if hit:
283
+ return float(hit.group(1).replace(",", "")), hit.group(0).strip()
284
+ hit = _COUNT_FIGURE_BOUNDARY.search(name or "")
285
+ if hit:
286
+ return float((hit.group(1) or hit.group(2)).replace(",", "")), hit.group(0).strip()
287
+ return None
288
+
289
+
273
290
  _KEY_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
274
291
  r"slower|larger|smaller|above|below|over|under|beyond|exceed\w*|beat\w*)(?:\s+than)?\s+"
275
292
  r"(\d+(?:\.\d+)?)$", re.I)
@@ -277,6 +294,9 @@ _KEY_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|wors
277
294
  _CLAIM_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
278
295
  r"slower|larger|smaller)\s+than\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\b(?:above|below|beyond|"
279
296
  r"exceed(?:s|ing)?)\s+\$?(-?\d[\d,]*(?:\.\d+)?)", re.I)
297
+ _CLAIM_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
298
+ r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:more|less|fewer|greater|higher|lower|better|worse|"
299
+ r"shorter|longer|cheaper|faster|slower|larger|smaller)\b", re.I)
280
300
  _SUBCLAUSE = re.compile(r",(?!\d)|[;:!?()]|\.(?!\d)|\b(?:and|but|while|whereas|although|so)\b", re.I)
281
301
 
282
302
 
@@ -298,9 +318,9 @@ def _count_figures(m: dict, rv: dict, figs: set[float] = frozenset()) -> set[flo
298
318
  if f is not None:
299
319
  return {f}
300
320
  out: set[float] = set()
301
- hit = _COUNT_FIGURE.search(str(m.get("name") or ""))
302
- if hit:
303
- out.add(float(hit.group(1).replace(",", "")))
321
+ named = _named_figure(str(m.get("name") or ""))
322
+ if named:
323
+ out.add(named[0])
304
324
  hit = _KEY_FIGURE.search(str(m.get("key") or "").replace("_", " "))
305
325
  if hit and (float(hit.group(1)) in figs or out):
306
326
  out.add(float(hit.group(1)))
@@ -323,86 +343,153 @@ _DIRECTION_WORDS = {
323
343
  }
324
344
  _DIRECTION_OF = {w: (axis, sign) for axis, signs in _DIRECTION_WORDS.items() for sign, ws in signs.items()
325
345
  for w in ws.split()}
326
- _OPS = {">": 1, ">=": 1, "<": -1, "<=": -1}
346
+ # a comparison is (axis, sign, strict): "longer than 26" is (long, +1, strict), "at least 26" and "26 or longer" are
347
+ # not strict, so a route of exactly 26 lies inside them and outside "longer than" (audit HG-023-02). Six operators;
348
+ # "=" and "!=" point nowhere.
349
+ _OPS = {">": (1, True), ">=": (1, False), "<": (-1, True), "<=": (-1, False), "=": None, "!=": None}
350
+ _CMP = r"(more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|dearer|faster|slower|larger|" \
351
+ r"smaller|bigger|earlier|later)"
352
+ _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)
353
+
354
+
355
+ def _comparisons_in(text: str) -> set[tuple[str, int, bool]]:
356
+ """Every comparison ``text`` makes, with its boundary: "at least", "at most", "not / no shorter than", "or
357
+ longer" include the figure; any other comparison word excludes it."""
358
+ text = (text or "").lower()
359
+ out = set()
360
+ for h in _NOT_STRICT.finditer(text):
361
+ if h.group(1):
362
+ out.add(("", 1 if h.group(1) == "least" else -1, False))
363
+ elif h.group(2):
364
+ axis, sign = _DIRECTION_OF[h.group(2)]
365
+ out.add((axis, -sign, False))
366
+ else:
367
+ out.add((*_DIRECTION_OF[h.group(3)], False))
368
+ rest = _NOT_STRICT.sub(" ", text)
369
+ return out | {(*_DIRECTION_OF[w], True) for w in re.findall(r"[a-z]+", rest) if w in _DIRECTION_OF}
327
370
 
328
371
 
329
372
  def _directions(text: str) -> set[tuple[str, int]]:
330
- return {_DIRECTION_OF[w] for w in re.findall(r"[a-z]+", (text or "").lower()) if w in _DIRECTION_OF}
373
+ return {(axis, sign) for axis, sign, _strict in _comparisons_in(text)}
331
374
 
332
375
 
333
- def _opposed(a: tuple[str, int], b: tuple[str, int]) -> bool:
376
+ def _opposed(a: tuple, b: tuple) -> bool:
334
377
  """Do these point opposite ways on one axis? An axis-free word ("more") meets any axis; two different axes
335
378
  ("longer", "faster") can't be compared, so they aren't opposed."""
336
379
  return a[1] != b[1] and (a[0] == b[0] or not a[0] or not b[0])
337
380
 
338
381
 
382
+ def _within(inner: tuple, outer: tuple) -> bool:
383
+ """Does every value ``inner`` takes lie in ``outer``? Same way on one axis, and the boundary inside ``outer`` if it
384
+ is inside ``inner``: "longer than 26" lies within "at least 26", not the other way round."""
385
+ return inner[1] == outer[1] and (inner[0] == outer[0] or not inner[0] or not outer[0]) and (
386
+ inner[2] or not outer[2])
387
+
388
+
389
+ def _negated(c: tuple) -> tuple:
390
+ """What lies outside a comparison: outside "longer than 26" is "26 or shorter"."""
391
+ return c[0], -c[1], not c[2]
392
+
393
+
339
394
  def stated_op(rv: dict) -> str | None:
340
395
  """The comparison a count says it made, written by the instrument beside it: {"k": 2, "n": 3, "figure": 26,
341
- "op": ">"} is two found above 26."""
396
+ "op": ">"} is two found above 26. One of < <= = != >= >."""
342
397
  op = rv.get("op")
343
- return op if op in _OPS else None
398
+ return op if isinstance(op, str) and op in _OPS else None
344
399
 
345
400
 
346
- def _count_directions(m: dict, rv: dict) -> set[tuple[str, int]]:
347
- """Every way a bound count could point; empty when nothing says. A comparison the result states is the only one.
348
- Otherwise its name's and its key's: as with its figure, the author writes the name, so a name that points the
349
- other way from the key adds a direction and never replaces one."""
401
+ def _count_comparisons(m: dict, rv: dict) -> set[tuple[str, int, bool]] | None:
402
+ """Every comparison a bound count could have made; empty when nothing says, None when it says it compared for
403
+ (in)equality. A comparison the result states is the only one. Otherwise its name's and its key's: as with its
404
+ figure, the author writes the name, so a name that disagrees with the key adds a comparison, never replaces one."""
350
405
  op = stated_op(rv)
351
406
  if op:
352
- return {("", _OPS[op])}
353
- return _directions(str(m.get("name") or "")) | _directions(str(m.get("key") or "").replace("_", " "))
407
+ return {("", *_OPS[op])} if _OPS[op] else None
408
+ return _comparisons_in(str(m.get("name") or "")) | _comparisons_in(str(m.get("key") or "").replace("_", " "))
354
409
 
355
410
 
356
- def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
357
- """Which way the exceptions to a claim point: those the absolute ``w``'s own clause compares against ("nothing
358
- longer than 26 km" — longer), else those beyond its extremes ("the shortest route is 26 km" — shorter)."""
359
- out: set[tuple[str, int]] = set()
360
- for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I) if w else ():
361
- end = next((x.start() for x in _SUBCLAUSE.finditer(stmt, m.end())), len(stmt))
362
- out |= {d for h in _CLAIM_FIGURE.finditer(stmt[m.end():end]) for d in _directions(h.group(0))}
363
- return out or {d for x in _extremes(stmt) for d in _directions(x)}
411
+ def _count_directions(m: dict, rv: dict) -> set[tuple[str, int]]:
412
+ return {(axis, sign) for axis, sign, _strict in _count_comparisons(m, rv) or ()}
364
413
 
365
414
 
366
- def _points(m: dict, rv: dict, a: tuple[int, int], dirs: set[tuple[str, int]]) -> bool:
367
- """Does this count point the way the claim's exceptions do? One that points the other way can't refute the claim
368
- or back it. When it could point either way (a name against its key), a count that finds some may still refute,
369
- and a count of none or all backs the claim only if no way it could point is the other way. A count, or a claim,
370
- whose direction can't be told is read as before."""
371
- c = _count_directions(m, rv)
372
- if not c or not dirs:
373
- return True
374
- against = {d for d in c if all(_opposed(d, x) for x in dirs)}
375
- return against != c if 0 < a[0] < a[1] else not against
415
+ # absolutes that say everything is so; the rest ("never", "nothing", "no route", "zero") say nothing is
416
+ _ALL = re.compile(r"^(?:always|all|every|universally|invariably|consistently|without exception|100\s*%)$", re.I)
417
+
376
418
 
419
+ def _clause_comparisons(clause: str) -> list[tuple[float, tuple[str, int, bool] | None]]:
420
+ """(figure, comparison) for each comparison against a figure in a claim's clause: "longer than 26 km", "at least
421
+ 26", "26 km or longer", "no more than 93", "above 93"; None for one that points nowhere ("beyond 93")."""
422
+ out = []
423
+ for h in _CLAIM_FIGURE.finditer(clause):
424
+ neg = re.search(r"\b(?:not|no)\s+$", clause[:h.start()], re.I) # "no more than 93" is at most 93
425
+ num = (h.group(1) or h.group(2)).replace(",", "")
426
+ out += [(float(num), c) for c in _comparisons_in(clause[neg.start() if neg else h.start():h.end()]) or {None}]
427
+ for h in _CLAIM_BOUNDARY.finditer(clause):
428
+ num = (h.group(1) or h.group(2)).replace(",", "")
429
+ out += [(float(num), c) for c in _comparisons_in(h.group(0)) or {None}]
430
+ return out
377
431
 
378
- def _absolute_figures(stmt: str, w: str) -> set[float]:
379
- """The figures an absolute is about: one its own clause compares against ("nothing worth more than 93 fits"),
380
- else the figures the statement's extremes name ("the highest is 93; nothing worth more fits")."""
381
- figs: set[float] = set()
432
+
433
+ def _absolute_targets(stmt: str, w: str) -> list[tuple[frozenset, frozenset]]:
434
+ """What an absolute rules out, as (figures, comparisons of the exceptions), one per thing it is about. A comparison
435
+ in its own clause: "nothing longer than 26 km" rules out longer than 26, and "every route is longer than 26 km"
436
+ rules out 26 or shorter (audit HG-023-01). Not "every" in a clause that states its own count ("a count of every
437
+ route finds 1 of 76 shorter than 25 km"): that names the set counted, not a claim about all of it. Else each
438
+ extreme the statement claims, on its own (HG-023-03)."""
439
+ out: list[tuple[frozenset, frozenset]] = []
440
+ every = bool(_ALL.match(w)) and not _names_stated_count(stmt, w)
382
441
  for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
383
442
  end = next((x.start() for x in _SUBCLAUSE.finditer(stmt, m.end())), len(stmt))
384
- figs |= {float((h.group(1) or h.group(2)).replace(",", "")) for h in _CLAIM_FIGURE.finditer(stmt[m.end():end])}
385
- return figs or _extreme_figures(stmt)
386
-
387
-
388
- def _bears(m: dict, rv: dict, a: tuple[int, int], figs: set[float],
389
- dirs: set[tuple[str, int]] = frozenset()) -> bool:
390
- """Does this count bear on a claim about ``figs``? One about another figure doesn't, whatever it finds: a zero
391
- beyond 140 can't vouch for 93, and two beyond 93 can't refute 95. When it could be about more than one figure (a
392
- name that disagrees with its key), a count that finds some bears if any of them is the claim's, and a count of
393
- none or all only if every one is: a label can widen what a count refutes, never what it vouches for. A count that
394
- names no figure, or a claim that names none, is read as before."""
395
- if not _points(m, rv, a, dirs):
396
- return False
443
+ for fig, c in _clause_comparisons(stmt[m.end():end]):
444
+ out.append((frozenset({fig}), frozenset() if c is None else frozenset({_negated(c) if every else c})))
445
+ return out or _extreme_targets(stmt) or [(frozenset(), frozenset())]
446
+
447
+
448
+ def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
449
+ """Which way the exceptions to a claim point."""
450
+ targets = _absolute_targets(stmt, w) if w else _extreme_targets(stmt)
451
+ return {(axis, sign) for _f, comps in targets for axis, sign, _strict in comps}
452
+
453
+
454
+ def _role(m: dict, rv: dict, a: tuple[int, int], figs: set[float], comps: set) -> str | None:
455
+ """What a bound count does to a claim that rules out ``comps`` at ``figs``: 'back' it (none of the exceptions
456
+ found), 'refute' it (some found), or nothing. A count of what lies outside the exceptions is read as its
457
+ complement: none of 3 routes longer than 26 is 3 of 3 at 26 or shorter. A count backs only if what it counted
458
+ covers the exceptions, and refutes only if every one it found is one: two routes at least 26 km don't refute "no
459
+ route is longer than 26", nor does none longer than 26 back "no route is 26 or longer" (audit HG-023-02). Where the
460
+ name and key could say different comparisons, a label can widen what a count refutes, never what it backs. A
461
+ count, or a claim, whose comparison can't be told is read as before: none or all backs, some refutes."""
462
+ k, n = a
463
+ cs = _count_comparisons(m, rv)
464
+ if cs is None and comps:
465
+ return None
466
+ if not cs or not comps:
467
+ role = "back" if k in (0, n) else "refute"
468
+ else:
469
+ roles = set()
470
+ for d in cs:
471
+ for e in comps:
472
+ dd, kk = (d, k) if not _opposed(d, e) else (_negated(d), n - k)
473
+ if dd[1] != e[1] or not (dd[0] == e[0] or not dd[0] or not e[0]):
474
+ roles.add(None)
475
+ elif kk == 0 and _within(e, dd):
476
+ roles.add("back")
477
+ elif kk > 0 and _within(dd, e):
478
+ roles.add("refute")
479
+ else:
480
+ roles.add(None)
481
+ role = "refute" if "refute" in roles else "back" if roles == {"back"} else None
482
+ if role is None:
483
+ return None
397
484
  c = _count_figures(m, rv, figs)
398
- if not c or not figs:
399
- return True
400
- return bool(c & figs) if 0 < a[0] < a[1] else c <= figs
485
+ if c and figs and not (c & figs if role == "refute" else c <= figs):
486
+ return None
487
+ return role
401
488
 
402
489
 
403
- def _pinned(m: dict, rv: dict, a: tuple[int, int], figs: set[float]) -> bool:
404
- """A count at the claim's own figure that finds exceptions: it refutes the claim whatever else is bound beside it."""
405
- return 0 < a[0] < a[1] and bool(figs) and bool(_count_figures(m, rv, figs) & figs)
490
+ def _pinned(m: dict, rv: dict, figs: set[float]) -> bool:
491
+ """A count at the claim's own figure: when it refutes the claim, it does whatever else is bound beside it."""
492
+ return bool(figs) and bool(_count_figures(m, rv, figs) & figs)
406
493
 
407
494
 
408
495
  def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
@@ -413,36 +500,36 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
413
500
  words = [w for w in words if not _PROVEN.match(w)] # an optimum with its bound (gap 0) is proven
414
501
  words = [w for w in words if not _names_counted_set(stmt, w, bound)]
415
502
  out: list[Problem] = []
416
- seen: set[frozenset] = set()
417
- for w in words: # one check per figure the absolutes are about
418
- figs = frozenset(_absolute_figures(stmt, w))
419
- if figs not in seen:
420
- seen.add(figs)
421
- out += [p for p in _absolute(stmt, scope, body, bound, w, words, set(figs)) if p.text() not in
422
- {q.text() for q in out}]
503
+ seen: set[tuple] = set()
504
+ for w in words: # one check per thing the absolutes rule out
505
+ for figs, comps in _absolute_targets(stmt, w):
506
+ if (figs, comps, _ALL.match(w) is None) not in seen:
507
+ seen.add((figs, comps, _ALL.match(w) is None))
508
+ out += [p for p in _absolute(stmt, scope, body, bound, w, words, set(figs), set(comps))
509
+ if p.text() not in {q.text() for q in out}]
423
510
  if not any(p.code == "COUNT_CONTRADICTS" for p in out):
424
511
  out += _superlative_contradicted(stmt, bound)
425
512
  return out
426
513
 
427
514
 
428
- def _absolute(stmt: str, scope: str, body: dict, bound: list, w: str, words: list, figs: set[float]) -> list[Problem]:
429
- dirs = _claim_directions(stmt, w)
430
- arms = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and _bears(m, rv, a, figs, dirs)]
515
+ def _absolute(stmt: str, scope: str, body: dict, bound: list, w: str, words: list, figs: set[float],
516
+ comps: set = frozenset()) -> list[Problem]:
517
+ arms = [(m, rv, a, role) for m, rv in bound if (a := _arm(rv)) and (role := _role(m, rv, a, figs, comps))]
431
518
  about = L.content_words(stmt)
432
519
  # a count at the claim's own figure that finds exceptions refutes it, whatever else is bound beside it
433
- pinned = [(m, a) for m, rv, a in arms if _pinned(m, rv, a, figs)
520
+ pinned = [(m, a) for m, rv, a, role in arms if role == "refute" and _pinned(m, rv, figs)
434
521
  and L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about]
435
522
  if pinned:
436
523
  m, (k, n) = max(pinned, key=lambda x: x[1][1])
437
524
  return [_count_contradicts(stmt, w, words, m, k, n)]
438
- exact = [(m, rv, a) for m, rv, a in arms if a[0] in (0, a[1])]
525
+ exact = [(m, rv, a) for m, rv, a, role in arms if role == "back"]
439
526
  if not exact:
440
- against = [(m, a) for m, _rv, a in arms if 0 < a[0] < a[1]
527
+ against = [(m, a) for m, _rv, a, role in arms if role == "refute"
441
528
  and L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about]
442
529
  if against:
443
530
  m, (k, n) = max(against, key=lambda x: x[1][1])
444
531
  return [_count_contradicts(stmt, w, words, m, k, n)]
445
- seen = ", ".join(f"{a[0]} of {a[1]:,}" for _m, _rv, a in arms)
532
+ seen = ", ".join(f"{a[0]} of {a[1]:,}" for _m, _rv, a, _role in arms)
446
533
  return [Problem("ABSOLUTE_WITHOUT_COUNT",
447
534
  f"'{w}' is absolute: back it with a k/n measurement of what it's about, where k is 0 or n, and "
448
535
  "keep the scope to the trials actually run — or say 'rarely' / 'in k of n'"
@@ -561,7 +648,7 @@ def _count_contradicts(stmt: str, w: str, words: list, m: dict, k: int, n: int)
561
648
  if int(b.replace(",", "")) == n and int(a.replace(",", "")) != k]
562
649
  sup = _extremes(stmt)
563
650
  head = (f"the statement says {said[0]:,} of {n:,}, but the record's own count is {k:,} of {n:,}" if said
564
- else f"'{sup[0] if sup else w}' is contradicted by the record's own count: {k:,} of {n:,}")
651
+ else f"'{w if w in sup else sup[0] if sup else w}' is contradicted by the record's own count: {k:,} of {n:,}")
565
652
  return Problem("COUNT_CONTRADICTS",
566
653
  f"{head} ({m.get('name') or m.get('key')}). The exceptions the statement rules out were "
567
654
  f"counted, so it is false as written: correct it, or say '{k:,} of {n:,}'",
@@ -578,30 +665,29 @@ def _qualified(stmt: str, k: int, n: int) -> bool:
578
665
 
579
666
  def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
580
667
  """An extreme ("the shortest route is 34 km") with no absolute word, where the record's own count of what lies
581
- beyond it is neither 0 nor all and no exact count backs any figure: refused as false (COUNT_CONTRADICTS)."""
668
+ beyond it finds some and no count backs it: refused as false (COUNT_CONTRADICTS). Each extreme is checked on its
669
+ own, with its own figure and direction: "the shortest is 26; the longest is 35" is two claims (audit HG-023-03)."""
582
670
  sup = _extremes(stmt)
583
671
  if not sup:
584
672
  return []
585
673
  about = L.content_words(stmt)
586
- figs = _extreme_figures(stmt)
587
- dirs = _claim_directions(stmt)
588
- rel = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and _bears(m, rv, a, figs, dirs)
589
- and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
590
- or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
591
- or stated_figure(rv) in figs)]
592
- # a count beyond the extreme's own figure that finds some refutes it, whatever else is bound beside it
593
- pinned = [(m, a) for m, rv, a in rel if _pinned(m, rv, a, figs) and not _qualified(stmt, *a)]
594
- rel = [(m, a) for m, _rv, a in rel]
595
- if pinned:
596
- m, (k, n) = max(pinned, key=lambda x: x[1][1])
597
- return [_count_contradicts(stmt, sup[0], sup, m, k, n)]
598
- if not rel or any(a[0] in (0, a[1]) for _m, a in rel):
599
- return []
600
- against = [(m, a) for m, a in rel if 0 < a[0] < a[1] and not _qualified(stmt, *a)]
601
- if not against:
602
- return []
603
- m, (k, n) = max(against, key=lambda x: x[1][1])
604
- return [_count_contradicts(stmt, sup[0], sup, m, k, n)]
674
+ for w, figs, comps in _extreme_targets(stmt, named=True):
675
+ rel = [(m, rv, a, role) for m, rv in bound if (a := _arm(rv))
676
+ and (role := _role(m, rv, a, set(figs), set(comps)))
677
+ and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
678
+ or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
679
+ or stated_figure(rv) in figs)
680
+ and not (role == "refute" and _qualified(stmt, *a))]
681
+ # a count beyond the extreme's own figure that finds some refutes it, whatever else is bound beside it
682
+ pinned = [(m, a) for m, rv, a, role in rel if role == "refute" and _pinned(m, rv, set(figs))]
683
+ if pinned:
684
+ m, (k, n) = max(pinned, key=lambda x: x[1][1])
685
+ return [_count_contradicts(stmt, w, sup, m, k, n)]
686
+ if not rel or any(x[-1] == "back" for x in rel):
687
+ continue
688
+ m, (k, n) = max(((m, a) for m, _rv, a, _role in rel), key=lambda x: x[1][1])
689
+ return [_count_contradicts(stmt, w, sup, m, k, n)]
690
+ return []
605
691
 
606
692
 
607
693
  # a count of what reaches a figure exactly: "fitting selections worth exactly 108", "routes exactly 33 km long"
@@ -609,11 +695,12 @@ _REACHES = re.compile(r"\bexactly\s+(-?\d[\d,]*(?:\.\d+)?)", re.I)
609
695
  _FIGURE = re.compile(r"\s*(?:[A-Za-z$]+\s+){0,2}?\$?(-?\d[\d,]*(?:\.\d+)?)(?![\d.,]?\d)(?!\s*of\s+\d)")
610
696
 
611
697
 
612
- def _extreme_figures(stmt: str) -> set[float]:
613
- """The figures a statement's asserted extremes name: the number its own predicate gives ("the highest total value
614
- that fits within 36 kg is 97" names 97, not 36), else every number in the extreme's clause. A figure elsewhere in
615
- the sentence ("..., not 97") is not one, nor a stated count ("the shortest route is 1 of 63 routes shorter")."""
616
- out = set()
698
+ def _extreme_claims(stmt: str) -> list[tuple[str, frozenset]]:
699
+ """(extreme, the figures it names) for each extreme the statement asserts: the number its own predicate gives
700
+ ("the highest total value that fits within 36 kg is 97" names 97, not 36), else every number in its clause. A
701
+ figure elsewhere in the sentence ("..., not 97") is not one, nor a stated count ("the shortest route is 1 of 63
702
+ routes shorter")."""
703
+ out = []
617
704
  for w in _extremes(stmt):
618
705
  for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
619
706
  end = next((x.start() for x in _CLAUSE.finditer(stmt, m.end())), len(stmt))
@@ -624,9 +711,26 @@ def _extreme_figures(stmt: str) -> set[float]:
624
711
  cop = _COPULA.search(after)
625
712
  fig = _FIGURE.match(after, cop.end()) if cop else None
626
713
  if fig:
627
- out.add(float(fig.group(1).replace(",", "")))
714
+ out.append((w, frozenset({float(fig.group(1).replace(",", ""))})))
628
715
  elif not (cop and re.match(r"\s*(?:\w+\s+){0,2}?-?\d[\d,]*\s+of\s+\d", after[cop.end():])):
629
- out |= {v for v, *_ in numbers(stmt[start:end])}
716
+ out.append((w, frozenset(v for v, *_ in numbers(stmt[start:end]))))
717
+ return out
718
+
719
+
720
+ def _extreme_figures(stmt: str) -> set[float]:
721
+ """The figures a statement's asserted extremes name."""
722
+ return {f for _w, figs in _extreme_claims(stmt) for f in figs}
723
+
724
+
725
+ def _extreme_targets(stmt: str, named: bool = False) -> list[tuple]:
726
+ """(figures, comparisons of the exceptions) for each extreme on its own: "the shortest route is 26 km" rules out
727
+ shorter than 26, and "the longest is 35" beside it rules out longer than 35, not shorter (audit HG-023-03). With
728
+ ``named``, the extreme first."""
729
+ out = []
730
+ for w, figs in _extreme_claims(stmt):
731
+ target = (w, figs, frozenset(_comparisons_in(w)))
732
+ if target[1:] not in [x[-2:] for x in out]:
733
+ out.append(target if named else target[1:])
630
734
  return out
631
735
 
632
736
 
@@ -825,34 +929,66 @@ _PREPOSITIONS = set("of in for from on at by with to among across within under o
825
929
 
826
930
  def _subject(text: str) -> tuple[str, frozenset[str]]:
827
931
  """What a unit is said of, as (head, modifiers): "every recorded parcel weight in the register" is (weight,
828
- {recorded, parcel}). Determiners go, and the subject ends at its first preposition."""
829
- words = []
932
+ {recorded, parcel}), and "weights of cargo" is (weight, {cargo}) (audit HG-023-04). Determiners go; the head ends
933
+ at its first preposition, and only what "of" or "for" names after it says which one."""
934
+ words: list[str] = []
935
+ tail: list[str] = []
936
+ after = ""
830
937
  for w in re.findall(r"[a-z]+", (text or "").lower()):
831
938
  if w in _PREPOSITIONS and words:
832
- break
833
- if w not in _DETERMINERS:
834
- words += sorted(L.content_words(w))
835
- return (words[-1], frozenset(words[:-1])) if words else ("", frozenset())
939
+ if after or w not in ("of", "for"):
940
+ break
941
+ after = w
942
+ elif w not in _DETERMINERS:
943
+ (tail if after else words).extend(sorted(L.content_words(w)))
944
+ return (words[-1], frozenset(words[:-1] + tail)) if words else ("", frozenset())
836
945
 
837
946
 
838
947
  def _same_thing(a: tuple[str, frozenset[str]], b: tuple[str, frozenset[str]]) -> bool:
839
- """Could these be one thing? Their heads must match, and two subjects each named for something the other isn't
840
- ("passenger weights", "shipping weights") are two things (audit HG-022-04). A bare "weights" may be either."""
841
- return bool(a[0]) and a[0] == b[0] and (not a[1] or not b[1] or bool(a[1] & b[1]))
948
+ """Could these be one thing? Their heads must match, and one must say no more than the other: "the recorded
949
+ shipping weights" are shipping weights, but "passenger weights" and "shipping weights" (HG-022-04), or "shipping
950
+ cargo weights" and "shipping passenger weights" (HG-023-04), each say something the other doesn't, so they are left
951
+ apart. A bare "weights" may be either. Left apart is unresolved, not a contradiction."""
952
+ return bool(a[0]) and a[0] == b[0] and (a[1] <= b[1] or b[1] <= a[1])
953
+
954
+
955
+ # what follows "rather than" / "instead of", up to its unit, is set aside, not asserted (audit HG-023-05)
956
+ _SET_ASIDE = re.compile(r"\b(?:rather\s+than|instead\s+of)\s+(?:[a-z]+\s+){0,2}?(?:"
957
+ + "|".join(sorted(_UNIT_OF, key=len, reverse=True)) + r")\b", re.I)
958
+ # words a clause may open with and still have no subject of its own: "but in kilograms", "but always in kilograms"
959
+ _NO_SUBJECT = set("but yet and in as using always only also all still entirely instead rather then though however "
960
+ "just simply".split())
961
+
962
+
963
+ def _set_aside(text: str) -> str:
964
+ return _SET_ASIDE.sub(" ", text)
842
965
 
843
966
 
844
967
  def _unit_assertions(text: str) -> list[tuple[tuple[str, frozenset[str]], set[tuple[str, str]], str]]:
845
968
  """(subject, units of record asserted of it, clause) for each clause that states one, outside the reach of
846
969
  negation: "the recorded weights are in pounds" says pounds of the recorded weights. A condition ("if the weights
847
- are pounds") or a hedge ("the weights might be recorded in pounds") asserts nothing."""
970
+ are pounds") or a hedge ("the weights might be recorded in pounds") asserts nothing. A clause with no subject or
971
+ verb of its own ("not in pounds but in kilograms") says its unit of the subject its sentence named last; one that
972
+ names its own ("and passenger weights in pounds") doesn't (audit HG-023-05)."""
848
973
  out = []
849
- for c in _UNIT_CLAUSE.split(_unnegated(text or "")):
850
- verb = _RECORDS.search(c)
851
- if not verb or _CONDITIONAL.search(c[:verb.start()]) or _HEDGED.search(c[:verb.start()]):
852
- continue
853
- subj = _subject(c[:verb.start()])
854
- if subj[0] and (units := _units_in(c[verb.end():])):
855
- out.append((subj, units, c.strip()))
974
+ for sent in re.split(r"(?<=[.!?;])\s+", _set_aside(text or "")):
975
+ last = None
976
+ for c in _UNIT_CLAUSE.split(_unnegated(sent)):
977
+ verb = _RECORDS.search(c)
978
+ if not verb:
979
+ units = _units_in(c)
980
+ first = next((h for h in re.finditer(r"[a-z]+", c.lower()) if h.group(0) in _UNIT_OF), None)
981
+ if last and units and first and all(w in _NO_SUBJECT for w in re.findall(r"[a-z]+",
982
+ c[:first.start()].lower())):
983
+ out.append((last, units, c.strip()))
984
+ continue
985
+ if _CONDITIONAL.search(c[:verb.start()]) or _HEDGED.search(c[:verb.start()]):
986
+ last = None
987
+ continue
988
+ subj = _subject(c[:verb.start()])
989
+ last = subj if subj[0] else None
990
+ if subj[0] and (units := _units_in(c[verb.end():])):
991
+ out.append((subj, units, c.strip()))
856
992
  return out
857
993
 
858
994