hypgraph 0.7.24__tar.gz → 0.7.26__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.24 → hypgraph-0.7.26}/CHANGELOG.md +46 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/PKG-INFO +33 -6
- {hypgraph-0.7.24 → hypgraph-0.7.26}/README.md +32 -5
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/PLAN.md +1 -1
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/RELEASE-READINESS.md +32 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/checks.py +263 -22
- hypgraph-0.7.26/tests/test_counts_that_bear.py +160 -0
- hypgraph-0.7.26/tests/test_what_a_count_is_of.py +152 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/.gitignore +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/AGENTS.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/CITATION.cff +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/DISCLAIMER.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/LICENSE +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/NOTICE +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/site-plan.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/spec.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/verification.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/examples/frameworks/README.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/pyproject.toml +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/blind.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/cli.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/demos/linked.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/graph.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/references.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/schema.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/server.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_anchoring.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_blind.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_blind_oracles.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_contradictions.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_coord.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_d10.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_direction_and_units.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_engine.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_framework_examples.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_i18n.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_linked_records.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_quantifiers_and_identity.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_references.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_trust_boundary.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_complete.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_lifecycle.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_scope.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_scoped_views.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_trust.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_views.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.26 — 2026-11-01: what a count is of, what its key compared, "and" with "or"
|
|
4
|
+
|
|
5
|
+
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
|
|
6
|
+
(`tests/test_what_a_count_is_of.py`).
|
|
7
|
+
|
|
8
|
+
- **A count names its kind in the singular too** (HG-025-01). Kinds were read only before a plural, so none of 3
|
|
9
|
+
"cargo route longer than 26 km" (or "cargo longer than 26 km") backed "no passenger route is longer than 26 km"
|
|
10
|
+
while the passenger count was 1 of 3. What a count names before its comparison is now read singular or plural: a
|
|
11
|
+
count of cargo routes neither backs nor refutes a claim about passenger routes. A word that says what was compared
|
|
12
|
+
("selections worth more") isn't read as the kind, and a bound in the statement ("the limit is at most 49 kg") isn't
|
|
13
|
+
what the claim is about.
|
|
14
|
+
- **A count's name can't change what its key counted** (HG-025-02). A count the lab keyed `route_speed_above_26`,
|
|
15
|
+
named "routes longer than 26 km" by the claimant, backed a claim about length. The key is the result's own name for
|
|
16
|
+
what it counted: a measurement whose name compares along another axis (length, not speed), or whose key counts a
|
|
17
|
+
thing neither the name nor the statement says ("cargo" under a name for passenger routes), is now
|
|
18
|
+
`MEASUREMENT_MISMATCH`. "route length above 26 km" still names a count of routes longer than 26 km.
|
|
19
|
+
- **"And" with "or" in one clause is left unresolved** (HG-025-03). The parts were checked one by one, so routes of 5,
|
|
20
|
+
20 and 25 km, which make "every route is longer than 10 km and shorter than 30 km or shorter than 6 km" true, gave
|
|
21
|
+
`COUNT_CONTRADICTS`. Which parts go together isn't settled by the words, so no count of a part backs or refutes such
|
|
22
|
+
a clause: it is `ABSOLUTE_WITHOUT_COUNT` until a count of the whole condition is bound, or the claim is split.
|
|
23
|
+
|
|
24
|
+
## 0.7.25 — 2026-11-01: which counts bear on a claim, joined comparisons, boundaries in names
|
|
25
|
+
|
|
26
|
+
Fixes from an independent audit of 0.7.24 (HG-024-01 to -04); each case below failed on 0.7.24 and is now a test
|
|
27
|
+
(`tests/test_counts_that_bear.py`).
|
|
28
|
+
|
|
29
|
+
- **A count with no comparison doesn't back one** (HG-024-01). With no comparison of its own, a count of none or all
|
|
30
|
+
was read the old way, so "3 of 3 routes processed" backed "nothing is longer than 26 km" and "every route is longer
|
|
31
|
+
than 26 km". A count that states no comparison now doesn't bear on a claim that rules one out
|
|
32
|
+
(`ABSOLUTE_WITHOUT_COUNT`). A claim with no comparison ("no route ever failed") reads a plain count as before.
|
|
33
|
+
- **A count of one kind doesn't back a claim about another** (HG-024-02). A count and a claim were matched on any shared
|
|
34
|
+
word, so none of 3 cargo routes longer than 26 km backed "no passenger route is longer than 26 km" while the passenger
|
|
35
|
+
count was 1 of 3, and backed "no route is longer" too. A count names what it counts in the plural ("cargo routes");
|
|
36
|
+
where the statement uses that word, the words right before it say which kind. A count backs a claim only if every
|
|
37
|
+
kind it names is one the statement names; a count of one kind that finds some still refutes a claim about all, and
|
|
38
|
+
two kinds named apart (cargo, passenger) are left unresolved.
|
|
39
|
+
- **Two comparisons joined in one clause** (HG-024-03). "And" ended the clause, so "no route is shorter than 20 km and
|
|
40
|
+
longer than 30 km" was refused by a route shorter than 20, and "every route is longer than 10 km and shorter than 30
|
|
41
|
+
km" went in with "shorter than 30" unread, even with the count that refutes it bound. An "and" or "or" before a
|
|
42
|
+
comparison with no subject of its own now keeps it in the clause. "No … A or B" and "every … A and B" are false if
|
|
43
|
+
either part fails; "no … A and B" and "every … A or B" rule out only what meets both, so none of one part backs them
|
|
44
|
+
and a count of each part on its own leaves them unresolved.
|
|
45
|
+
- **A count's name must say its boundary** (HG-024-04). Only a name pointing the other way was caught: a `>=` count
|
|
46
|
+
named "routes longer than 26 km", or a `>` count named "26 km or longer", went in. A name that leaves the figure out
|
|
47
|
+
where the count took it in, or the other way round, is now `MEASUREMENT_MISMATCH`.
|
|
48
|
+
|
|
3
49
|
## 0.7.24 — 2026-11-01: what a quantifier needs, where a comparison's boundary falls, one check per extreme
|
|
4
50
|
|
|
5
51
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.26
|
|
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/
|
|
@@ -38,13 +38,22 @@ The evidence layer for AI research agents.<br>
|
|
|
38
38
|
<a href="https://hypgraph.com">hypgraph.com</a> · <a href="https://hypgraph.com/agents/">for agents</a> ·
|
|
39
39
|
<a href="https://hypgraph.com/engineers/">for engineers</a> · <a href="https://hypgraph.com/spec/">record format</a></p>
|
|
40
40
|
|
|
41
|
+
<p align="center"><picture>
|
|
42
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/record-dark.png">
|
|
43
|
+
<img src="https://hypgraph.com/assets/readme/record-light.png" width="880" alt="The Graph view of a real research record: one question at the centre, 27 hypotheses around it, experiments and runs fanning out into 90 results, and 22 claims at the rim">
|
|
44
|
+
</picture></p>
|
|
45
|
+
<p align="center"><sub>A real research record in the HypGraph UI: one question, 27 hypotheses, 12 experiments and their runs, 90 results, 22 claims, 395 hash-chained events. <a href="https://hypgraph.com/examples/">Open an example live</a>.</sub></p>
|
|
46
|
+
|
|
41
47
|
---
|
|
42
48
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
Research agents write fluently, and fluency hides overclaims. Fifty clean prompts become "the model never leaks". A
|
|
50
|
+
search too weak to find anything becomes "ruled out". Three copies of one result become three independent lines of
|
|
51
|
+
evidence, and a number appears that no result contains. Every sentence reads fine, and the next claim, decision or
|
|
52
|
+
deployment gets built on top of it.
|
|
53
|
+
|
|
54
|
+
HypGraph is the record agents write into instead of prose. It checks each claim the moment it's written: its numbers
|
|
55
|
+
must come from a result, its "never" needs the trials behind it, its "better" needs both sides measured. When the
|
|
56
|
+
arithmetic doesn't hold, the claim is refused, with a code, the math, and the sentence that would be honest:
|
|
48
57
|
|
|
49
58
|
```text
|
|
50
59
|
$ hypgraph add claim statement="The model never leaks training data." exposure=1000000 exposure_unit=users …
|
|
@@ -61,6 +70,15 @@ The honest version goes in, and it carries its consequence wherever it's read:
|
|
|
61
70
|
active claim · unchecked · 1 independent line of support · … · 0 of 50 = at most 5.8% at 95%, up to 58,155 of 1,000,000 not ruled out
|
|
62
71
|
```
|
|
63
72
|
|
|
73
|
+
On the website, the same engine refused these as the page was built. Each gets a code and what to fix; the
|
|
74
|
+
rewrite underneath, on the same evidence, got in:
|
|
75
|
+
|
|
76
|
+
<p align="center"><a href="https://hypgraph.com/play/"><picture>
|
|
77
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/refusals-dark.png">
|
|
78
|
+
<img src="https://hypgraph.com/assets/readme/refusals-light.png" width="880" alt="Three overclaims struck through — 'always beats the baseline', '5 points better', 'never leaks training data' — each with its refusal code and what to fix, and the honest rewrite accepted as CL1, CL2, CL3">
|
|
79
|
+
</picture></a></p>
|
|
80
|
+
<p align="center"><sub>Try to get a claim past it yourself: <a href="https://hypgraph.com/play/">hypgraph.com/play</a>.</sub></p>
|
|
81
|
+
|
|
64
82
|
## The arithmetic of never
|
|
65
83
|
|
|
66
84
|
"Never" is the most expensive word in research, because everything built on it inherits it. Zero failures in *n*
|
|
@@ -133,6 +151,15 @@ Nothing in the record is ever *set* as known. How well each claim is known is **
|
|
|
133
151
|
Every write is an event in a **SHA-256 hash chain**. A record can be handed to a reviewer, a regulator or another
|
|
134
152
|
team, and they can verify it without trusting you.
|
|
135
153
|
|
|
154
|
+
Records can build on each other. A source in one record pins a node of another: the version it read and that
|
|
155
|
+
version's hash. The UI draws every pinned record beside yours, verifies each one, and checks each pin against it.
|
|
156
|
+
|
|
157
|
+
<p align="center"><a href="https://hypgraph.com/examples/linked/#view=graph"><picture>
|
|
158
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/linked-dark.png">
|
|
159
|
+
<img src="https://hypgraph.com/assets/readme/linked-light.png" width="880" alt="Three hash-chained records drawn together — London's water 1854–1866, Broad Street 1854 and East London 1866 — joined by pinned references labelled with the version each one read, every pin checked">
|
|
160
|
+
</picture></a></p>
|
|
161
|
+
<p align="center"><sub>Three records, each its own hash chain, joined only by pinned references. <a href="https://hypgraph.com/examples/linked/#view=graph">Open it live</a>.</sub></p>
|
|
162
|
+
|
|
136
163
|
## One record, five disciplines
|
|
137
164
|
|
|
138
165
|
The rules above are the scientific case written as code. A **profile** says what a domain means by evidence,
|
|
@@ -6,13 +6,22 @@ The evidence layer for AI research agents.<br>
|
|
|
6
6
|
<a href="https://hypgraph.com">hypgraph.com</a> · <a href="https://hypgraph.com/agents/">for agents</a> ·
|
|
7
7
|
<a href="https://hypgraph.com/engineers/">for engineers</a> · <a href="https://hypgraph.com/spec/">record format</a></p>
|
|
8
8
|
|
|
9
|
+
<p align="center"><picture>
|
|
10
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/record-dark.png">
|
|
11
|
+
<img src="https://hypgraph.com/assets/readme/record-light.png" width="880" alt="The Graph view of a real research record: one question at the centre, 27 hypotheses around it, experiments and runs fanning out into 90 results, and 22 claims at the rim">
|
|
12
|
+
</picture></p>
|
|
13
|
+
<p align="center"><sub>A real research record in the HypGraph UI: one question, 27 hypotheses, 12 experiments and their runs, 90 results, 22 claims, 395 hash-chained events. <a href="https://hypgraph.com/examples/">Open an example live</a>.</sub></p>
|
|
14
|
+
|
|
9
15
|
---
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
Research agents write fluently, and fluency hides overclaims. Fifty clean prompts become "the model never leaks". A
|
|
18
|
+
search too weak to find anything becomes "ruled out". Three copies of one result become three independent lines of
|
|
19
|
+
evidence, and a number appears that no result contains. Every sentence reads fine, and the next claim, decision or
|
|
20
|
+
deployment gets built on top of it.
|
|
21
|
+
|
|
22
|
+
HypGraph is the record agents write into instead of prose. It checks each claim the moment it's written: its numbers
|
|
23
|
+
must come from a result, its "never" needs the trials behind it, its "better" needs both sides measured. When the
|
|
24
|
+
arithmetic doesn't hold, the claim is refused, with a code, the math, and the sentence that would be honest:
|
|
16
25
|
|
|
17
26
|
```text
|
|
18
27
|
$ hypgraph add claim statement="The model never leaks training data." exposure=1000000 exposure_unit=users …
|
|
@@ -29,6 +38,15 @@ The honest version goes in, and it carries its consequence wherever it's read:
|
|
|
29
38
|
active claim · unchecked · 1 independent line of support · … · 0 of 50 = at most 5.8% at 95%, up to 58,155 of 1,000,000 not ruled out
|
|
30
39
|
```
|
|
31
40
|
|
|
41
|
+
On the website, the same engine refused these as the page was built. Each gets a code and what to fix; the
|
|
42
|
+
rewrite underneath, on the same evidence, got in:
|
|
43
|
+
|
|
44
|
+
<p align="center"><a href="https://hypgraph.com/play/"><picture>
|
|
45
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/refusals-dark.png">
|
|
46
|
+
<img src="https://hypgraph.com/assets/readme/refusals-light.png" width="880" alt="Three overclaims struck through — 'always beats the baseline', '5 points better', 'never leaks training data' — each with its refusal code and what to fix, and the honest rewrite accepted as CL1, CL2, CL3">
|
|
47
|
+
</picture></a></p>
|
|
48
|
+
<p align="center"><sub>Try to get a claim past it yourself: <a href="https://hypgraph.com/play/">hypgraph.com/play</a>.</sub></p>
|
|
49
|
+
|
|
32
50
|
## The arithmetic of never
|
|
33
51
|
|
|
34
52
|
"Never" is the most expensive word in research, because everything built on it inherits it. Zero failures in *n*
|
|
@@ -101,6 +119,15 @@ Nothing in the record is ever *set* as known. How well each claim is known is **
|
|
|
101
119
|
Every write is an event in a **SHA-256 hash chain**. A record can be handed to a reviewer, a regulator or another
|
|
102
120
|
team, and they can verify it without trusting you.
|
|
103
121
|
|
|
122
|
+
Records can build on each other. A source in one record pins a node of another: the version it read and that
|
|
123
|
+
version's hash. The UI draws every pinned record beside yours, verifies each one, and checks each pin against it.
|
|
124
|
+
|
|
125
|
+
<p align="center"><a href="https://hypgraph.com/examples/linked/#view=graph"><picture>
|
|
126
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/linked-dark.png">
|
|
127
|
+
<img src="https://hypgraph.com/assets/readme/linked-light.png" width="880" alt="Three hash-chained records drawn together — London's water 1854–1866, Broad Street 1854 and East London 1866 — joined by pinned references labelled with the version each one read, every pin checked">
|
|
128
|
+
</picture></a></p>
|
|
129
|
+
<p align="center"><sub>Three records, each its own hash chain, joined only by pinned references. <a href="https://hypgraph.com/examples/linked/#view=graph">Open it live</a>.</sub></p>
|
|
130
|
+
|
|
104
131
|
## One record, five disciplines
|
|
105
132
|
|
|
106
133
|
The rules above are the scientific case written as code. A **profile** says what a domain means by evidence,
|
|
@@ -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.26 (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
|
|
@@ -149,6 +149,38 @@ was taken from a changelog without being checked against the code.*
|
|
|
149
149
|
> claim layer (subject, property, comparator, threshold, unit, quantifier) so that strong verdicts rest on typed fields
|
|
150
150
|
> rather than prose; that is not built. Released as 0.7.24.
|
|
151
151
|
|
|
152
|
+
> **Audit of 0.7.24 (2026-11-01), fixed for 0.7.25.** A fifth independent audit named four candidate defects; each
|
|
153
|
+
> reproduced on the PyPI 0.7.24 wheel, plus four of the same kind. A count with no comparison of its own ("3 of 3
|
|
154
|
+
> routes processed") backed "nothing is longer than 26 km" and "every route is longer" (HG-024-01). A count of cargo
|
|
155
|
+
> routes backed a claim about passenger routes, and about every route, because they shared the word "route"
|
|
156
|
+
> (HG-024-02). "And" ended a clause, so a true "no route is shorter than 20 km and longer than 30 km" was refused,
|
|
157
|
+
> and "every route is longer than 10 km and shorter than 30 km" went in with its second half unread, even beside the
|
|
158
|
+
> count that refutes it (HG-024-03). A `>=` count named "longer than 26 km" went in (HG-024-04). A count that states no
|
|
159
|
+
> comparison no longer bears on one; a count backs only a claim whose kinds it names, and kinds named apart are
|
|
160
|
+
> unresolved; a comparison joined by "and" / "or" stays in its clause and is read as both or either; a name must say
|
|
161
|
+
> the boundary its count used. Each case is a regression test written to fail on 0.7.24. Replaying the 1,900 recorded
|
|
162
|
+
> adversarial entries changes 7: one honest correction is no longer refused, and one correction that got in only
|
|
163
|
+
> because an unrelated zero count backed it (the HG-024-01 case) is now refused; none of the entries tries these cases.
|
|
164
|
+
>
|
|
165
|
+
> **Where that stands.** #91 is merged with green CI (17 of 17 checks); from the 0.7.25 wheel in a fresh venv the
|
|
166
|
+
> suite is 574 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Kinds are read from a plural noun
|
|
167
|
+
> and the words before it, a heuristic; the typed claim layer the audits recommend is not built. Released as 0.7.25.
|
|
168
|
+
|
|
169
|
+
> **Audit of 0.7.25 (2026-11-01), fixed for 0.7.26.** A sixth independent audit named three defects; each reproduced on
|
|
170
|
+
> the PyPI 0.7.25 wheel. Kinds were read only before a plural, so none of 3 "cargo route longer than 26 km" backed "no
|
|
171
|
+
> passenger route is longer than 26 km" while the passenger count was 1 of 3 (HG-025-01). A count the lab keyed
|
|
172
|
+
> `route_speed_above_26`, named "routes longer than 26 km" by the claimant, backed a claim about length (HG-025-02). A
|
|
173
|
+
> clause joining comparisons with both "and" and "or" was checked part by part, so a true one drew
|
|
174
|
+
> `COUNT_CONTRADICTS` (HG-025-03). What a count names before its comparison is now read singular or plural; a name
|
|
175
|
+
> that compares along another axis than its key, or a key that counts a kind neither the name nor the statement says,
|
|
176
|
+
> is `MEASUREMENT_MISMATCH`; a mixed "and" / "or" clause is unresolved until the whole condition is counted. Each case
|
|
177
|
+
> is a regression test written to fail on 0.7.25. Replaying the 1,900 recorded adversarial entries changes none; the
|
|
178
|
+
> replay caught two over-reaches of a first draft (a value word, "selections worth more", read as the kind; a bound,
|
|
179
|
+
> "the limit is at most 49 kg", read as the subject), now tests too.
|
|
180
|
+
>
|
|
181
|
+
> **Where that stands.** #96 is merged with green CI (17 of 17 checks). Kinds and subjects are still read from the
|
|
182
|
+
> words around a comparison, a heuristic; the typed claim layer the audits recommend is not built. Released as 0.7.26.
|
|
183
|
+
|
|
152
184
|
## Recommendation: CONDITIONAL GO
|
|
153
185
|
|
|
154
186
|
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.26"
|
|
20
20
|
|
|
21
21
|
from .bundle import anchored, verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -185,8 +185,9 @@ def _matches_stat(printed: tuple, candidates: list[float]) -> bool:
|
|
|
185
185
|
return printed[1] >= 1 and _matches(printed, candidates)
|
|
186
186
|
|
|
187
187
|
|
|
188
|
-
def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]:
|
|
189
|
-
"""Measurements that check out against their results, and problems with the rest.
|
|
188
|
+
def _bound(s: Snap, meas: list, stmt: str = "") -> tuple[list[tuple[dict, dict]], list[Problem]]:
|
|
189
|
+
"""Measurements that check out against their results, and problems with the rest. With ``stmt``, the claim's
|
|
190
|
+
statement: what a key says it counted must be said by the measurement's name or by the statement."""
|
|
190
191
|
good, problems = [], []
|
|
191
192
|
for i, m in enumerate(meas):
|
|
192
193
|
if not isinstance(m, dict) or not m.get("result") or not m.get("key"):
|
|
@@ -219,11 +220,52 @@ def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]
|
|
|
219
220
|
f"way from what result {m['result']} counted for '{m['key']}' ({op})",
|
|
220
221
|
fix=f"name the count after the comparison it made ({op})"))
|
|
221
222
|
bad = True
|
|
223
|
+
# "longer than 26" leaves 26 out, "at least 26" and "26 or longer" take it in: a name that says the other
|
|
224
|
+
# boundary from the one counted says a different count (audit HG-024-04)
|
|
225
|
+
if not bad and _OPS.get(op) and any(c and not _opposed(c, ("", _OPS[op][0])) and c[2] != _OPS[op][1]
|
|
226
|
+
for _f, c in _clause_comparisons(str(m.get("name") or ""))):
|
|
227
|
+
problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: its name "
|
|
228
|
+
f"{'leaves out' if _OPS[op][1] else 'takes in'} the figure itself, but result "
|
|
229
|
+
f"{m['result']} counted '{m['key']}' with {op}",
|
|
230
|
+
fix=f"name the count after the comparison it made ({op})"))
|
|
231
|
+
bad = True
|
|
232
|
+
# the key is the result's own name for what it counted, written by whoever ran it; the measurement's name is
|
|
233
|
+
# the claimant's. A name may shorten the key, not change it: "routes longer than 26 km" isn't a count of
|
|
234
|
+
# route_speed_above_26, nor of routes_faster_than_26 (audit HG-025-02)
|
|
235
|
+
key = str(m["key"]).replace("_", " ")
|
|
236
|
+
named_axes = {c[0] for c in _comparisons_in(str(m.get("name") or "")) if c[0]}
|
|
237
|
+
key_axes = {c[0] for c in _comparisons_in(key) if c[0]}
|
|
238
|
+
if not bad and named_axes and key_axes and not named_axes & key_axes:
|
|
239
|
+
problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: its name compares "
|
|
240
|
+
f"{_axis_words(named_axes)}, but result {m['result']} counted '{m['key']}', which "
|
|
241
|
+
f"compares {_axis_words(key_axes)}",
|
|
242
|
+
fix="name the count after what it compared, or bind the count that compared that"))
|
|
243
|
+
bad = True
|
|
244
|
+
# a name that says no comparison ("fitting selections") doesn't say another count; one that does must keep
|
|
245
|
+
# what the key counted, here or in the statement
|
|
246
|
+
unsaid = _unsaid(_subject_of(key), f"{m.get('name') or ''} . {stmt}")
|
|
247
|
+
if not bad and unsaid and _comparisons_in(str(m.get("name") or "")):
|
|
248
|
+
problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: result {m['result']} "
|
|
249
|
+
f"counted '{m['key']}', and neither the name nor the statement says what it "
|
|
250
|
+
f"counted ({', '.join(sorted(_key_words(key, unsaid)))})",
|
|
251
|
+
fix="name the count after what it counted, or bind the count of what the claim "
|
|
252
|
+
"is about"))
|
|
253
|
+
bad = True
|
|
222
254
|
if not bad:
|
|
223
255
|
good.append((m, rv))
|
|
224
256
|
return good, problems
|
|
225
257
|
|
|
226
258
|
|
|
259
|
+
def _axis_words(axes: set[str]) -> str:
|
|
260
|
+
return " / ".join(sorted("/".join(w for w, (a, _s) in _DIRECTION_OF.items() if a == x and not w.endswith("est"))
|
|
261
|
+
for x in axes))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _key_words(key: str, stems: set[str]) -> set[str]:
|
|
265
|
+
"""The words of ``key`` whose stems are ``stems``, as written."""
|
|
266
|
+
return {w for w in re.findall(r"[a-z]+", key.lower()) if L.content_words(w) & stems} or stems
|
|
267
|
+
|
|
268
|
+
|
|
227
269
|
def _exposure_note(bound_rate: float, body: dict) -> str:
|
|
228
270
|
x = body.get("exposure")
|
|
229
271
|
if not isinstance(x, (int, float)) or x <= 0:
|
|
@@ -430,19 +472,59 @@ def _clause_comparisons(clause: str) -> list[tuple[float, tuple[str, int, bool]
|
|
|
430
472
|
return out
|
|
431
473
|
|
|
432
474
|
|
|
433
|
-
|
|
475
|
+
# a target group whose parts are joined by both "and" and "or" (``_target_groups``)
|
|
476
|
+
MIXED = (frozenset(), frozenset({("mixed", 0, False)}))
|
|
477
|
+
|
|
478
|
+
# "and" or "or" before a comparison with no subject of its own: "shorter than 20 km and longer than 30 km"
|
|
479
|
+
_JOINED = re.compile(r"\s+(?:also\s+)?(?:(?:not|no)\s+)?(?:" + _CMP + r"\s+than\b|at\s+(?:least|most)\b|above\b|below\b|"
|
|
480
|
+
r"beyond\b|exceed)", re.I)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _clause_end(stmt: str, start: int) -> int:
|
|
484
|
+
"""Where the clause that starts at ``start`` ends. An "and" that joins a second comparison to the first ("no route
|
|
485
|
+
is shorter than 20 km and longer than 30 km") doesn't end it: the two say one thing (audit HG-024-03)."""
|
|
486
|
+
for x in _SUBCLAUSE.finditer(stmt, start):
|
|
487
|
+
if not (x.group(0).lower() == "and" and _JOINED.match(stmt, x.end())):
|
|
488
|
+
return x.start()
|
|
489
|
+
return len(stmt)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _target_groups(stmt: str, w: str) -> list[list[tuple[frozenset, frozenset]]]:
|
|
434
493
|
"""What an absolute rules out, as (figures, comparisons of the exceptions), one per thing it is about. A comparison
|
|
435
494
|
in its own clause: "nothing longer than 26 km" rules out longer than 26, and "every route is longer than 26 km"
|
|
436
495
|
rules out 26 or shorter (audit HG-023-01). Not "every" in a clause that states its own count ("a count of every
|
|
437
496
|
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
|
-
|
|
497
|
+
extreme the statement claims, on its own (HG-023-03).
|
|
498
|
+
|
|
499
|
+
Each group is ruled out together. Two comparisons joined in one clause are two groups where either exception
|
|
500
|
+
falsifies the claim ("no route is shorter than 20 km or longer than 30 km"; "every route is longer than 10 km and
|
|
501
|
+
shorter than 30 km"), and one group where only an exception to both does ("no route is shorter than 20 km and
|
|
502
|
+
longer than 30 km" rules out a route that is both; "every route is shorter than 20 km or longer than 30 km" one that
|
|
503
|
+
is neither) (audit HG-024-03).
|
|
504
|
+
|
|
505
|
+
A clause that joins comparisons with both "and" and "or" ("every route is longer than 10 km and shorter than 30 km
|
|
506
|
+
or shorter than 6 km") is one group marked ``MIXED``: which parts go together depends on a grouping the words don't
|
|
507
|
+
settle, so no count of a part backs or refutes it (audit HG-025-03)."""
|
|
508
|
+
out: list[list[tuple[frozenset, frozenset]]] = []
|
|
440
509
|
every = bool(_ALL.match(w)) and not _names_stated_count(stmt, w)
|
|
441
510
|
for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
511
|
+
clause = stmt[m.end():_clause_end(stmt, m.end())]
|
|
512
|
+
targets = [(frozenset({fig}), frozenset() if c is None else frozenset({_negated(c) if every else c}))
|
|
513
|
+
for fig, c in _clause_comparisons(clause)]
|
|
514
|
+
ands = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\band\b", clause, re.I))
|
|
515
|
+
ors = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\bor\b", clause, re.I))
|
|
516
|
+
if len(targets) > 1 and ands and ors:
|
|
517
|
+
out.append([MIXED, *targets])
|
|
518
|
+
elif len(targets) > 1 and ands != ors and (ands != every):
|
|
519
|
+
out.append(targets)
|
|
520
|
+
else:
|
|
521
|
+
out += [[x] for x in targets]
|
|
522
|
+
return out or [[x] for x in _extreme_targets(stmt) or [(frozenset(), frozenset())]]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _absolute_targets(stmt: str, w: str) -> list[tuple[frozenset, frozenset]]:
|
|
526
|
+
"""Every thing an absolute rules out, each on its own (``_target_groups`` says which go together)."""
|
|
527
|
+
return [x for group in _target_groups(stmt, w) for x in group if x is not MIXED]
|
|
446
528
|
|
|
447
529
|
|
|
448
530
|
def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
|
|
@@ -451,19 +533,21 @@ def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
|
|
|
451
533
|
return {(axis, sign) for _f, comps in targets for axis, sign, _strict in comps}
|
|
452
534
|
|
|
453
535
|
|
|
454
|
-
def _role(m: dict, rv: dict, a: tuple[int, int], figs: set[float], comps: set) -> str | None:
|
|
536
|
+
def _role(m: dict, rv: dict, a: tuple[int, int], figs: set[float], comps: set, stmt: str = "") -> str | None:
|
|
455
537
|
"""What a bound count does to a claim that rules out ``comps`` at ``figs``: 'back' it (none of the exceptions
|
|
456
538
|
found), 'refute' it (some found), or nothing. A count of what lies outside the exceptions is read as its
|
|
457
539
|
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
540
|
covers the exceptions, and refutes only if every one it found is one: two routes at least 26 km don't refute "no
|
|
459
541
|
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
|
-
|
|
542
|
+
name and key could say different comparisons, a label can widen what a count refutes, never what it backs. A count
|
|
543
|
+
that says no comparison doesn't bear on a claim that rules one out: 3 of 3 routes processed says nothing about
|
|
544
|
+
routes longer than 26 km (audit HG-024-01). A claim whose comparison can't be told is read as before: none or all
|
|
545
|
+
backs, some refutes. With ``stmt``, the count must also be about what the claim is (``_subject_fit``)."""
|
|
462
546
|
k, n = a
|
|
463
547
|
cs = _count_comparisons(m, rv)
|
|
464
|
-
if
|
|
548
|
+
if comps and not cs:
|
|
465
549
|
return None
|
|
466
|
-
if not
|
|
550
|
+
if not comps:
|
|
467
551
|
role = "back" if k in (0, n) else "refute"
|
|
468
552
|
else:
|
|
469
553
|
roles = set()
|
|
@@ -484,9 +568,150 @@ def _role(m: dict, rv: dict, a: tuple[int, int], figs: set[float], comps: set) -
|
|
|
484
568
|
c = _count_figures(m, rv, figs)
|
|
485
569
|
if c and figs and not (c & figs if role == "refute" else c <= figs):
|
|
486
570
|
return None
|
|
571
|
+
if stmt:
|
|
572
|
+
covers, apart = _subject_fit(stmt, m)
|
|
573
|
+
if (role == "back" and not covers) or (role == "refute" and apart):
|
|
574
|
+
return None
|
|
487
575
|
return role
|
|
488
576
|
|
|
489
577
|
|
|
578
|
+
# words a count's name may use for the counting itself, not for what it counts
|
|
579
|
+
_COUNTING = set("count counts counted counting number numbers total totals tally share fraction rate found measured "
|
|
580
|
+
"checked matching".split())
|
|
581
|
+
# and words that say how many, not which kind: "nothing worth more", "every route"
|
|
582
|
+
_HOW_MANY = set("nothing none never nobody every always ever each whole entire".split())
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _modifiers(words: list[str], i: int) -> set[str]:
|
|
586
|
+
"""The content words right before ``words[i]``, up to a determiner, number, preposition or comparison: "no passenger
|
|
587
|
+
route" gives {passeng}, "the 3 cargo routes" {cargo}, "the shortest route" nothing."""
|
|
588
|
+
out: set[str] = set()
|
|
589
|
+
for w in reversed(words[:i]):
|
|
590
|
+
cw = L.content_words(w)
|
|
591
|
+
if not cw or w in _DETERMINERS or w in _PREPOSITIONS or w in _DIRECTION_OF or w in _COUNTING | _HOW_MANY:
|
|
592
|
+
break
|
|
593
|
+
out |= cw
|
|
594
|
+
return out
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _subject_of(text: str) -> set[str]:
|
|
598
|
+
"""What a count against a figure is of: the content words right before its first comparison ("cargo route longer
|
|
599
|
+
than 26 km" and "cargo_routes_longer_26" give {cargo, route}; "speed above 26" {speed}); empty when it makes no
|
|
600
|
+
comparison or names nothing before it. Singular or plural alike (audit HG-025-01)."""
|
|
601
|
+
words = [w for w in re.findall(r"[a-z]+|\d[\d.,]*", text.lower().replace("_", " ")) if w not in _COPULAS]
|
|
602
|
+
i = next((j for j, w in enumerate(words) if w in _DIRECTION_OF or w == "at" and words[j + 1:j + 2] in
|
|
603
|
+
(["least"], ["most"])), None)
|
|
604
|
+
return _modifiers(words, i) if i else set()
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _subject_parts(text: str) -> tuple[set[str], set[str]]:
|
|
608
|
+
"""``_subject_of`` split into its head, the word right before the comparison, and the words before that: "cargo
|
|
609
|
+
route longer" gives ({route}, {cargo}), "fitting selections worth more" ({selection}, {fit})."""
|
|
610
|
+
words = [w for w in re.findall(r"[a-z]+|\d[\d.,]*", text.lower().replace("_", " ")) if w not in _COPULAS]
|
|
611
|
+
i = next((j for j, w in enumerate(words) if w in _DIRECTION_OF or w == "at" and words[j + 1:j + 2] in
|
|
612
|
+
(["least"], ["most"])), None)
|
|
613
|
+
if not i:
|
|
614
|
+
return set(), set()
|
|
615
|
+
whole = _modifiers(words, i)
|
|
616
|
+
while i > 1 and words[i - 1] in _MEASURED_BY: # "selections worth more": the head is selections
|
|
617
|
+
whole -= L.content_words(words[i - 1])
|
|
618
|
+
i -= 1
|
|
619
|
+
head = L.content_words(words[i - 1]) & whole
|
|
620
|
+
return head, whole - head
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
# words between what a count is of and its comparison that say what was compared, not which thing
|
|
624
|
+
_MEASURED_BY = set("worth valued priced weighing costing lasting taking".split())
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
_COPULAS = set("is are was were be been being".split())
|
|
628
|
+
# the property a comparison word compares: a count of "route length above 26" is said by "no route is longer than 26"
|
|
629
|
+
_PROPERTY_AXIS = {s: axis for axis, ws in {"long": "length distance", "fast": "speed velocity pace",
|
|
630
|
+
"cost": "cost price", "time": "date time"}.items()
|
|
631
|
+
for w in ws.split() for s in L.content_words(w)}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _unsaid(stems: set[str], text: str) -> set[str]:
|
|
635
|
+
"""``stems`` that ``text`` doesn't say: neither as words nor, for a property, by comparing along its axis."""
|
|
636
|
+
axes = {c[0] for c in _comparisons_in(text) if c[0]}
|
|
637
|
+
return {s for s in stems - L.content_words(text) if _PROPERTY_AXIS.get(s) not in axes}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _subjects_at(stmt: str, axes: set[str]) -> set[str]:
|
|
641
|
+
"""What a statement's comparisons along ``axes`` are about: "no passenger route is longer than 26 km" gives
|
|
642
|
+
{passenger, route} for length; "nothing worth more" nothing. A bound ("the limit is at most 49 kg") is not one."""
|
|
643
|
+
words = [w for w in re.findall(r"[a-z]+|\d[\d.,]*", stmt.lower()) if w not in _COPULAS | _MEASURED_BY]
|
|
644
|
+
return set().union(*(_modifiers(words, j) for j, w in enumerate(words) if w in _DIRECTION_OF
|
|
645
|
+
and _DIRECTION_OF[w][0] in axes and words[j - 1:j] != ["at"]))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _kinds_named(stmt: str) -> set[str]:
|
|
649
|
+
"""The kinds a statement names for what it is about: the words before its comparison ("no passenger route is
|
|
650
|
+
longer" gives {passenger, route}) and before a plural in a phrase that says what it ranges over ("among the 3
|
|
651
|
+
passenger routes" gives {passenger}; "the recorded weights" and "the limit applies" name nothing it ranges over)."""
|
|
652
|
+
said = re.findall(r"[a-z]+|\d[\d.,]*", stmt.lower())
|
|
653
|
+
out = _subject_of(stmt)
|
|
654
|
+
for i, w in enumerate(said):
|
|
655
|
+
if not (w.endswith("s") and not w.endswith("ss") and L.content_words(w)):
|
|
656
|
+
continue
|
|
657
|
+
mods = _modifiers(said, i)
|
|
658
|
+
j = i - 1
|
|
659
|
+
while j >= 0 and L.content_words(said[j]) & mods:
|
|
660
|
+
j -= 1
|
|
661
|
+
while j >= 0 and (said[j] in _DETERMINERS or said[j][0].isdigit()):
|
|
662
|
+
j -= 1
|
|
663
|
+
if j >= 0 and said[j] in _PREPOSITIONS:
|
|
664
|
+
out |= mods
|
|
665
|
+
return out
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _subject_fit(stmt: str, m: dict) -> tuple[bool, bool]:
|
|
669
|
+
"""Is a count about what a claim is about? Returns (covers, apart). A count names what it counts in the plural
|
|
670
|
+
("cargo routes", "fitting selections"); where the statement uses that word too, the words right before it on each
|
|
671
|
+
side say which kind: "cargo routes" are cargo routes. The count covers the claim if every kind it names is one the
|
|
672
|
+
statement names too: none of 3 cargo routes longer than 26 km says nothing of passenger routes, nor of every route
|
|
673
|
+
(audit HG-024-02). They are apart if each names a kind the other doesn't (cargo, passenger); a count of all routes
|
|
674
|
+
with one longer may still be about the passenger route. A count that names nothing the statement does is read as
|
|
675
|
+
before.
|
|
676
|
+
|
|
677
|
+
The same holds for what a count names before its comparison, singular or plural: "cargo route longer than 26 km"
|
|
678
|
+
and "cargo longer than 26 km" are of cargo routes, and back no claim about passenger routes (audit HG-025-01). A
|
|
679
|
+
count whose subject the statement doesn't name at all ("speed above 26" against a claim about length) neither backs
|
|
680
|
+
nor refutes it."""
|
|
681
|
+
said = re.findall(r"[a-z]+", stmt.lower())
|
|
682
|
+
stems = [L.content_words(w) for w in said]
|
|
683
|
+
about = set().union(*stems) if stems else set()
|
|
684
|
+
text = f"{m.get('name') or ''} . {str(m.get('key') or '').replace('_', ' ')}".lower()
|
|
685
|
+
words = re.findall(r"[a-z]+|\.", text)
|
|
686
|
+
mine = L.content_words(text)
|
|
687
|
+
covers, apart = True, False
|
|
688
|
+
for i, w in enumerate(words):
|
|
689
|
+
head = L.content_words(w)
|
|
690
|
+
plural = w.endswith("s") and not w.endswith("ss")
|
|
691
|
+
if not head or not plural or w in _DIRECTION_OF or w in _COUNTING | _HOW_MANY or not head & about:
|
|
692
|
+
continue
|
|
693
|
+
kinds = _modifiers(words, i) - about
|
|
694
|
+
theirs = set().union(*(_modifiers(said, j) for j, s in enumerate(stems) if s & head)) - mine
|
|
695
|
+
covers = covers and not kinds
|
|
696
|
+
apart = apart or bool(kinds and theirs)
|
|
697
|
+
parts = [_subject_parts(str(m.get(f) or "")) for f in ("name", "key")]
|
|
698
|
+
head, mods = parts[0][0] | parts[1][0], parts[0][1] | parts[1][1]
|
|
699
|
+
if head:
|
|
700
|
+
subject = head | mods
|
|
701
|
+
if not _unsaid(head, stmt): # the statement says the count's head: its other words must be said too
|
|
702
|
+
kinds = _unsaid(mods, stmt)
|
|
703
|
+
theirs = set().union(*(_modifiers(said, j) for j, s in enumerate(stems) if s & head)) - subject
|
|
704
|
+
apart = apart or bool(kinds and theirs)
|
|
705
|
+
else: # a head the statement doesn't say only matters where it names a kind itself
|
|
706
|
+
named = _kinds_named(stmt)
|
|
707
|
+
kinds = _unsaid(subject, stmt) if named else set()
|
|
708
|
+
axes = {c[0] for f in ("name", "key") for c in _comparisons_in(str(m.get(f) or "").replace("_", " "))}
|
|
709
|
+
theirs = _subjects_at(stmt, axes)
|
|
710
|
+
apart = apart or bool(theirs and not theirs & subject)
|
|
711
|
+
covers = covers and not kinds
|
|
712
|
+
return covers, apart
|
|
713
|
+
|
|
714
|
+
|
|
490
715
|
def _pinned(m: dict, rv: dict, figs: set[float]) -> bool:
|
|
491
716
|
"""A count at the claim's own figure: when it refutes the claim, it does whatever else is bound beside it."""
|
|
492
717
|
return bool(figs) and bool(_count_figures(m, rv, figs) & figs)
|
|
@@ -502,11 +727,27 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
|
|
|
502
727
|
out: list[Problem] = []
|
|
503
728
|
seen: set[tuple] = set()
|
|
504
729
|
for w in words: # one check per thing the absolutes rule out
|
|
505
|
-
for
|
|
506
|
-
if (
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
730
|
+
for group in _target_groups(stmt, w):
|
|
731
|
+
if (tuple(group), _ALL.match(w) is None) in seen:
|
|
732
|
+
continue
|
|
733
|
+
seen.add((tuple(group), _ALL.match(w) is None))
|
|
734
|
+
if group[0] is MIXED:
|
|
735
|
+
p = Problem("ABSOLUTE_WITHOUT_COUNT", f"'{w}' rules on comparisons joined by both 'and' and 'or': "
|
|
736
|
+
"counts of each comparison on its own can't settle that either way. Count what meets the "
|
|
737
|
+
"whole condition, or split it into claims that each join their comparisons one way",
|
|
738
|
+
fix="count what meets the whole condition, or split the claim", words=words)
|
|
739
|
+
if p.text() not in {q.text() for q in out}:
|
|
740
|
+
out.append(p)
|
|
741
|
+
continue
|
|
742
|
+
got = [_absolute(stmt, scope, body, bound, w, words, set(figs), set(comps)) for figs, comps in group]
|
|
743
|
+
if len(group) > 1: # ruled out together: none of one part means none of both
|
|
744
|
+
if any(not g for g in got):
|
|
745
|
+
continue
|
|
746
|
+
got = [g for g in got if not any(p.code == "COUNT_CONTRADICTS" for p in g)][:1] or [[Problem(
|
|
747
|
+
"ABSOLUTE_WITHOUT_COUNT", f"'{w}' rules out what meets both comparisons at once: a count of each "
|
|
748
|
+
"on its own can't show that something meets both. Back it with a count of either one where k is "
|
|
749
|
+
"0, or count both together", fix="count what meets both comparisons together", words=words)]]
|
|
750
|
+
out += [p for g in got for p in g if p.text() not in {q.text() for q in out}]
|
|
510
751
|
if not any(p.code == "COUNT_CONTRADICTS" for p in out):
|
|
511
752
|
out += _superlative_contradicted(stmt, bound)
|
|
512
753
|
return out
|
|
@@ -514,7 +755,7 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
|
|
|
514
755
|
|
|
515
756
|
def _absolute(stmt: str, scope: str, body: dict, bound: list, w: str, words: list, figs: set[float],
|
|
516
757
|
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))]
|
|
758
|
+
arms = [(m, rv, a, role) for m, rv in bound if (a := _arm(rv)) and (role := _role(m, rv, a, figs, comps, stmt))]
|
|
518
759
|
about = L.content_words(stmt)
|
|
519
760
|
# a count at the claim's own figure that finds exceptions refutes it, whatever else is bound beside it
|
|
520
761
|
pinned = [(m, a) for m, rv, a, role in arms if role == "refute" and _pinned(m, rv, figs)
|
|
@@ -673,7 +914,7 @@ def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
|
|
|
673
914
|
about = L.content_words(stmt)
|
|
674
915
|
for w, figs, comps in _extreme_targets(stmt, named=True):
|
|
675
916
|
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)))
|
|
917
|
+
and (role := _role(m, rv, a, set(figs), set(comps), stmt))
|
|
677
918
|
and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
|
|
678
919
|
or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
|
|
679
920
|
or stated_figure(rv) in figs)
|
|
@@ -1377,7 +1618,7 @@ def claim_issues(s: Snap, body: dict, support: list[str], inv: str) -> list[Prob
|
|
|
1377
1618
|
if kind in ("finding", "exclusion", "inconclusive") and not support:
|
|
1378
1619
|
problems.append(Problem("EVIDENCE_MISSING", "link the evidence: at least one result, claim or source that "
|
|
1379
1620
|
"supports it (links: {\"rel\": \"supports\", \"from\": <result id>, \"why\": …})"))
|
|
1380
|
-
bound, bad = _bound(s, body.get("measurements") or [])
|
|
1621
|
+
bound, bad = _bound(s, body.get("measurements") or [], stmt)
|
|
1381
1622
|
problems += bad
|
|
1382
1623
|
clean = _DATE.sub(" ", _LABEL.sub(" ", stmt))
|
|
1383
1624
|
plain, stat = derived_numbers(bound)
|