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