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.
- {hypgraph-0.7.23 → hypgraph-0.7.24}/CHANGELOG.md +35 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/PKG-INFO +1 -1
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/PLAN.md +1 -1
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/RELEASE-READINESS.md +18 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/checks.py +254 -118
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/language.py +6 -3
- hypgraph-0.7.24/tests/test_quantifiers_and_identity.py +207 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/.gitignore +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/AGENTS.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/CITATION.cff +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/DISCLAIMER.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/LICENSE +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/NOTICE +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/README.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/site-plan.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/spec.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/verification.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/examples/frameworks/README.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/pyproject.toml +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/blind.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/cli.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/demos/linked.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/graph.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/references.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/schema.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/server.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_anchoring.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_blind.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_blind_oracles.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_contradictions.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_coord.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_d10.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_direction_and_units.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_engine.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_framework_examples.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_i18n.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_linked_records.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_references.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_trust_boundary.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_complete.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_lifecycle.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_scope.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_scoped_views.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_trust.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_verify_views.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.23 → hypgraph-0.7.24}/tests/test_vocabulary_friction.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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 =
|
|
211
|
-
if not bad and said and f is not None and
|
|
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
|
|
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
|
-
|
|
302
|
-
if
|
|
303
|
-
out.add(
|
|
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
|
-
|
|
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 {
|
|
373
|
+
return {(axis, sign) for axis, sign, _strict in _comparisons_in(text)}
|
|
331
374
|
|
|
332
375
|
|
|
333
|
-
def _opposed(a: tuple
|
|
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
|
|
347
|
-
"""Every
|
|
348
|
-
Otherwise its name's and its key's: as with its
|
|
349
|
-
|
|
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
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
"""
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
399
|
-
return
|
|
400
|
-
return
|
|
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,
|
|
404
|
-
"""A count at the claim's own figure
|
|
405
|
-
return
|
|
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[
|
|
417
|
-
for w in words: # one check per
|
|
418
|
-
figs
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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]
|
|
429
|
-
|
|
430
|
-
arms = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
613
|
-
"""
|
|
614
|
-
that fits within 36 kg is 97" names 97, not 36), else every number in
|
|
615
|
-
the sentence ("..., not 97") is not one, nor a stated count ("the shortest route is 1 of 63
|
|
616
|
-
|
|
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.
|
|
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
|
|
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})
|
|
829
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
|
840
|
-
|
|
841
|
-
|
|
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
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|