hypgraph 0.8.1__tar.gz → 0.8.3__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.8.1 → hypgraph-0.8.3}/CHANGELOG.md +30 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/PKG-INFO +29 -1
- {hypgraph-0.8.1 → hypgraph-0.8.3}/README.md +28 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/PLAN.md +1 -1
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/RELEASE-READINESS.md +12 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/checks.py +35 -11
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/cli.py +17 -6
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/demos/__init__.py +1 -1
- hypgraph-0.8.3/src/hypgraph/demos/dispute.py +168 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/plan.py +3 -2
- hypgraph-0.8.3/tests/test_demo_dispute.py +52 -0
- hypgraph-0.8.3/tests/test_next_order.py +45 -0
- hypgraph-0.8.3/tests/test_quoted_counterexample.py +107 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/.gitignore +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/AGENTS.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/CITATION.cff +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/DISCLAIMER.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/LICENSE +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/NOTICE +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/site-plan.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/spec.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/ui-plan.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/verification.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/workflows/README.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/workflows/software.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/docs/workflows/trading.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/examples/frameworks/README.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/pyproject.toml +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/assertions.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/blind.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/demos/linked.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/graph.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/language.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/references.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/schema.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/server.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/store.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_anchoring.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_assertions.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_blind.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_blind_oracles.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_borrowed_words.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_bound_support.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_contradictions.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_coord.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_counts_that_bear.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_d10.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_direction_and_units.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_engine.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_figure_said_twice.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_framework_examples.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_i18n.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_incremental.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_link_translate.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_linked_records.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_option_lead.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_order_detail.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_profiles.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_quantifiers_and_identity.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_references.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_refusals.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_relation_words.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_report_answers.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_server_auth.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_stats_values.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_surfaces.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_trust_boundary.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_typed_audits.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_ui_app.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_verify_complete.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_verify_lifecycle.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_verify_scope.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_verify_scoped_views.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_verify_trust.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_verify_views.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_what_a_count_is_of.py +0 -0
- {hypgraph-0.8.1 → hypgraph-0.8.3}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.3 — 2026-11-03: the next-experiments view in a stable order
|
|
4
|
+
|
|
5
|
+
- **`verify --standing` no longer fails on a sound bundle with two or more untested hypotheses.** `next_experiments`
|
|
6
|
+
listed `untested_hypotheses` by iterating a set of ids, so the order followed Python's per-process string hashing:
|
|
7
|
+
a bundle exported in one process carried one order and its re-derivation another, and `hypgraph verify --standing`
|
|
8
|
+
reported a standing mismatch (`<inv>/next`) on a record whose content was identical. The list now follows the order
|
|
9
|
+
the hypotheses were added. Found on a 785-node record; that record and three smaller ones now derive identically
|
|
10
|
+
under six hash seeds.
|
|
11
|
+
|
|
12
|
+
## 0.8.2 — 2026-11-01: an extreme that quotes its own counterexample; a disputed answer
|
|
13
|
+
|
|
14
|
+
- **A quoted counterexample no longer excuses an extreme.** A stated count used to exempt an extreme whenever its
|
|
15
|
+
clause said "k of n ... than", meant for a count about a figure of the statement's own ("the shortest is 21 km; 1 of
|
|
16
|
+
77 are shorter than 26 km"). It also exempted the count at the extreme's own figure, so "the shortest route is 30 km,
|
|
17
|
+
since 1 of 77 are shorter than 30 km" was accepted. The count is now an exception to the extreme whenever its figure,
|
|
18
|
+
on the result or in the sentence, is the extreme's own.
|
|
19
|
+
- **Two readings that exemption had hidden.** "The highest ... is therefore not 125" is a denial (an adverb between
|
|
20
|
+
the copula and "not"), and a bound in the extreme's own direction ("the shortest is at most 30 km", "91 or more") is
|
|
21
|
+
not a claim that the figure is the extreme. A bound the other way ("the shortest is at least 30 km") still claims it.
|
|
22
|
+
- Replayed on 1,900 recorded claim writes: 5 change, all now refused. Four are an extreme stated beside the count
|
|
23
|
+
that shows it false. One is a retraction worded as the claim ("the highest is 80, but this is not correct: 3 of
|
|
24
|
+
1,024 are worth more"). Who the record names as still saying something false is unchanged or better on every set.
|
|
25
|
+
- **`hypgraph demo dispute`: a disputed answer.** A teaching record in which two parties answer one question from the
|
|
26
|
+
same road register. Ada makes an honest mistake, the record refuses it (`COUNT_CONTRADICTS`) and she corrects it.
|
|
27
|
+
Mallory cites the register's units note for a claim it contradicts, is refused twice (`SOURCE_CONTRADICTS`) and files
|
|
28
|
+
it without the note. An independent reviewer checks one claim and fails the other, so the Evidence view shows one
|
|
29
|
+
answer checked and one contested, and `Graph.conduct()` names who kept saying it. It is also on the website
|
|
30
|
+
(`examples/dispute/`, in every site language) and in the README. `hypgraph demo` with no name still builds Broad
|
|
31
|
+
Street.
|
|
32
|
+
|
|
3
33
|
## 0.8.1 — 2026-11-01: exact figures, units within their kind
|
|
4
34
|
|
|
5
35
|
An independent audit of 0.8.0 found two defects in the typed checks, both confirmed on the published wheel.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
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/
|
|
@@ -136,6 +136,34 @@ CL1 — withdrawn claim · …
|
|
|
136
136
|
⚠ 2 nodes rest on CL1: 1 decision (D1); 1 claim (CL2) — now AT RISK
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
+
## Calling out a bad answer
|
|
140
|
+
|
|
141
|
+
Two agents answer one question from the same road register. One makes an honest mistake and the other lies. The
|
|
142
|
+
record tells them apart, and nobody has to label anyone:
|
|
143
|
+
|
|
144
|
+
<p align="center"><a href="https://hypgraph.com/examples/dispute/"><picture>
|
|
145
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/dispute-dark.png">
|
|
146
|
+
<img src="https://hypgraph.com/assets/readme/dispute-light.png" width="880" alt="The Evidence view of a disputed answer: Ada's claim that the shortest route is 29 km is checked, Mallory's claim that the register is in miles is contested, above the two route counts the instrument filed and the seven register items they rest on">
|
|
147
|
+
</picture></a></p>
|
|
148
|
+
<p align="center"><sub>Standing derived from the record: one answer checked, the other contested. <code>hypgraph demo dispute</code> builds it; <a href="https://hypgraph.com/examples/dispute/">open it live</a>.</sub></p>
|
|
149
|
+
|
|
150
|
+
- **Ada** says the shortest route is 30 km. The instrument counted 1 of the 4 routes as shorter, so the record refuses
|
|
151
|
+
it (`COUNT_CONTRADICTS`). She corrects it to 29 km, which the count backs.
|
|
152
|
+
- **Mallory** says the register is in miles, and cites the register's own units note as support. The note says
|
|
153
|
+
kilometres, so the record refuses it (`SOURCE_CONTRADICTS`). She says it again and is refused again.
|
|
154
|
+
|
|
155
|
+
The record can't catch every lie by itself: once Mallory stops citing the note, her claim goes in. But it keeps what
|
|
156
|
+
she was shown. The reviewer fails her claim against the note, and the conduct log (`hypgraph refusals`,
|
|
157
|
+
`Graph.conduct()`) says who corrected what they were shown and who kept saying it:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
$ hypgraph demo dispute
|
|
161
|
+
…
|
|
162
|
+
What the record says of each author:
|
|
163
|
+
ada the record's own count contradicted a claim of this author's 1 time; the author did not say it again, and corrected it 1 time
|
|
164
|
+
mallory 1 claim of this author's went against a source it cited as support; the author said it again 2 times after being shown, and never corrected it; its last word still says it
|
|
165
|
+
```
|
|
166
|
+
|
|
139
167
|
## Derived, never asserted
|
|
140
168
|
|
|
141
169
|
Nothing in the record is ever *set* as known. How well each claim is known is **derived on every read**:
|
|
@@ -104,6 +104,34 @@ CL1 — withdrawn claim · …
|
|
|
104
104
|
⚠ 2 nodes rest on CL1: 1 decision (D1); 1 claim (CL2) — now AT RISK
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Calling out a bad answer
|
|
108
|
+
|
|
109
|
+
Two agents answer one question from the same road register. One makes an honest mistake and the other lies. The
|
|
110
|
+
record tells them apart, and nobody has to label anyone:
|
|
111
|
+
|
|
112
|
+
<p align="center"><a href="https://hypgraph.com/examples/dispute/"><picture>
|
|
113
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://hypgraph.com/assets/readme/dispute-dark.png">
|
|
114
|
+
<img src="https://hypgraph.com/assets/readme/dispute-light.png" width="880" alt="The Evidence view of a disputed answer: Ada's claim that the shortest route is 29 km is checked, Mallory's claim that the register is in miles is contested, above the two route counts the instrument filed and the seven register items they rest on">
|
|
115
|
+
</picture></a></p>
|
|
116
|
+
<p align="center"><sub>Standing derived from the record: one answer checked, the other contested. <code>hypgraph demo dispute</code> builds it; <a href="https://hypgraph.com/examples/dispute/">open it live</a>.</sub></p>
|
|
117
|
+
|
|
118
|
+
- **Ada** says the shortest route is 30 km. The instrument counted 1 of the 4 routes as shorter, so the record refuses
|
|
119
|
+
it (`COUNT_CONTRADICTS`). She corrects it to 29 km, which the count backs.
|
|
120
|
+
- **Mallory** says the register is in miles, and cites the register's own units note as support. The note says
|
|
121
|
+
kilometres, so the record refuses it (`SOURCE_CONTRADICTS`). She says it again and is refused again.
|
|
122
|
+
|
|
123
|
+
The record can't catch every lie by itself: once Mallory stops citing the note, her claim goes in. But it keeps what
|
|
124
|
+
she was shown. The reviewer fails her claim against the note, and the conduct log (`hypgraph refusals`,
|
|
125
|
+
`Graph.conduct()`) says who corrected what they were shown and who kept saying it:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
$ hypgraph demo dispute
|
|
129
|
+
…
|
|
130
|
+
What the record says of each author:
|
|
131
|
+
ada the record's own count contradicted a claim of this author's 1 time; the author did not say it again, and corrected it 1 time
|
|
132
|
+
mallory 1 claim of this author's went against a source it cited as support; the author said it again 2 times after being shown, and never corrected it; its last word still says it
|
|
133
|
+
```
|
|
134
|
+
|
|
107
135
|
## Derived, never asserted
|
|
108
136
|
|
|
109
137
|
Nothing in the record is ever *set* as known. How well each claim is known is **derived on every read**:
|
|
@@ -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.8.
|
|
112
|
+
## Status at 0.8.3 (2026-11-03)
|
|
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
|
|
@@ -249,6 +249,18 @@ was taken from a changelog without being checked against the code.*
|
|
|
249
249
|
> the statement. Untyped mode keeps its documented limits. #104 is merged with green CI (17 of 17 checks). Released as
|
|
250
250
|
> 0.8.1.
|
|
251
251
|
|
|
252
|
+
> **After 0.8.1 (2026-11-01).** The frozen word checks were changed once, for a soundness gap rather than a new
|
|
253
|
+
> wording: a stated count exempted an extreme even when the count was at the extreme's own figure, so an extreme that
|
|
254
|
+
> quoted its own counterexample was accepted. The exemption now holds only for a count about another figure; an adverb
|
|
255
|
+
> before "not" and a bound in the extreme's own direction are read as what they are. Replaying the 1,900 recorded
|
|
256
|
+
> claim writes changes 5, all now refused (4 lies, 1 retraction worded as the claim). #106 is merged with green CI.
|
|
257
|
+
> Released as 0.8.2, with the `dispute` demo (#107).
|
|
258
|
+
|
|
259
|
+
> **After 0.8.2 (2026-11-03).** One determinism fix in a derived view, no change to the checks: `next_experiments`
|
|
260
|
+
> listed untested hypotheses in a set's hash order, so `verify --standing` could report a mismatch on a sound bundle
|
|
261
|
+
> with two or more of them when the exporting and verifying processes hashed strings differently. The list now follows
|
|
262
|
+
> the order the hypotheses were added. #109 is merged with green CI. Released as 0.8.3.
|
|
263
|
+
|
|
252
264
|
## Recommendation: CONDITIONAL GO
|
|
253
265
|
|
|
254
266
|
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.8.
|
|
19
|
+
__version__ = "0.8.3"
|
|
20
20
|
|
|
21
21
|
from .bundle import anchored, verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -955,22 +955,34 @@ def claimed_extremes(stmt: str) -> list[str]:
|
|
|
955
955
|
if not _enumerated(stmt, w) and not _names_stated_count(stmt, w)]
|
|
956
956
|
|
|
957
957
|
|
|
958
|
+
# an adverb between the copula and its negation: "the highest ... is therefore not 125"
|
|
959
|
+
_ADVERB = r"(?:(?:therefore|thus|hence|then|so|also|still|\w+ly)\s+)?"
|
|
958
960
|
# the extreme's own predicate denied after it, in its clause: "the highest total value ... is not 130"
|
|
959
|
-
_DENIED_EXTREME = re.compile(r"\b(?:is|are|was|were)\s+(?:not|no longer|never)\b
|
|
961
|
+
_DENIED_EXTREME = re.compile(r"\b(?:is|are|was|were)\s+" + _ADVERB + r"(?:not|no longer|never)\b|"
|
|
962
|
+
r"\b(?:isn't|aren't|wasn't|weren't)\b|"
|
|
960
963
|
# or said to lie beyond a figure: "the true maximum is higher than 98"
|
|
961
964
|
r"\b(?:is|are|was|were|lies)\s+(?:\w+\s+)?(?:higher|lower|greater|larger|smaller|more|less|"
|
|
962
965
|
r"above|below|over|under|shorter|longer|beyond)\b", re.I)
|
|
963
966
|
# or denied before it: "120 is not the highest total value", "isn't the shortest"
|
|
964
|
-
_DENIED_BEFORE = re.compile(r"(?:\b(?:is|are|was|were)\s+(?:not|no longer|never)
|
|
967
|
+
_DENIED_BEFORE = re.compile(r"(?:\b(?:is|are|was|were)\s+" + _ADVERB + r"(?:not|no longer|never)|"
|
|
968
|
+
r"\b(?:isn't|aren't|wasn't|weren't))"
|
|
965
969
|
r"\s+(?:the\s+|a\s+|an\s+)?(?:true\s+|real\s+|actual\s+)?$", re.I)
|
|
966
970
|
_COPULA = re.compile(r"\b(?:is|are|was|were|isn't|aren't|wasn't|weren't|lies)\b", re.I)
|
|
967
971
|
|
|
968
972
|
|
|
969
|
-
def _denied(after: str) -> bool:
|
|
973
|
+
def _denied(after: str, w: str = "") -> bool:
|
|
970
974
|
"""Does the extreme's own predicate — the first copula after it — deny it or put it beyond a figure? Only that
|
|
971
|
-
one: "the highest value is 98 ..., so the true maximum is higher" still claims 98.
|
|
975
|
+
one: "the highest value is 98 ..., so the true maximum is higher" still claims 98. A bound in the extreme's own
|
|
976
|
+
direction is beyond its figure too: "the highest value is at least 91" (or "91 or more") says the true maximum is
|
|
977
|
+
91 or above, which what lies beyond 91 doesn't contradict; "the highest is at most 91" still claims a figure."""
|
|
972
978
|
cop = _COPULA.search(after)
|
|
973
|
-
|
|
979
|
+
if not cop:
|
|
980
|
+
return False
|
|
981
|
+
if _DENIED_EXTREME.match(after, cop.start()):
|
|
982
|
+
return True
|
|
983
|
+
own = {s for _a, s, _strict in _comparisons_in(w)}
|
|
984
|
+
pred = after[cop.end():].split(",")[0]
|
|
985
|
+
return bool(own) and any(not strict and s in own for _a, s, strict in _comparisons_in(pred))
|
|
974
986
|
|
|
975
987
|
|
|
976
988
|
# a count named for what lies beyond a figure ("fitting selections worth more", "shorter routes connecting") is about
|
|
@@ -988,7 +1000,7 @@ def _extremes(stmt: str) -> list[str]:
|
|
|
988
1000
|
for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
|
|
989
1001
|
end = next((x.start() for x in _CLAUSE.finditer(stmt, m.end())), len(stmt))
|
|
990
1002
|
start = max((x.end() for x in _CLAUSE.finditer(stmt, 0, m.start())), default=0)
|
|
991
|
-
if (not _denied(stmt[m.end():end]) and not _DENIED_BEFORE.search(stmt[start:m.start()])
|
|
1003
|
+
if (not _denied(stmt[m.end():end], w) and not _DENIED_BEFORE.search(stmt[start:m.start()])
|
|
992
1004
|
and re.search(r"\d", stmt[start:end])):
|
|
993
1005
|
out.append(w)
|
|
994
1006
|
break
|
|
@@ -1011,11 +1023,23 @@ def _count_contradicts(stmt: str, w: str, words: list, m: dict, k: int, n: int)
|
|
|
1011
1023
|
words=words + [f"={_fmt(v)}" for v in _subject_numbers(stmt, k, n)])
|
|
1012
1024
|
|
|
1013
1025
|
|
|
1014
|
-
|
|
1026
|
+
_THAN_FIGURE = re.compile(r"\bthan\s+\$?(-?\d[\d,]*(?:\.\d+)?)", re.I)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _qualified(stmt: str, k: int, n: int, figs: frozenset = frozenset(), rv: dict | None = None) -> bool:
|
|
1015
1030
|
"""Does the statement give this count about a figure of its own ("1 of 61 routes are shorter than 26 km")?
|
|
1016
|
-
Then it's a count of something beyond that figure, not of exceptions to the extreme it claims.
|
|
1031
|
+
Then it's a count of something beyond that figure, not of exceptions to the extreme it claims. Not when the
|
|
1032
|
+
figure is the extreme's own: "the shortest route is 30 km, since 1 of 77 are shorter than 30 km" states the very
|
|
1033
|
+
count that shows it false, and quoting the counterexample doesn't make an extreme true."""
|
|
1034
|
+
if figs and rv is not None and stated_figure(rv) in figs:
|
|
1035
|
+
return False
|
|
1017
1036
|
forms = {f"{k} of {n}", f"{k:,} of {n:,}"}
|
|
1018
|
-
|
|
1037
|
+
for c in _CLAUSE.split(stmt):
|
|
1038
|
+
if any(f in c for f in forms) and re.search(r"\bthan\b", c):
|
|
1039
|
+
said = {float(x.replace(",", "")) for x in _THAN_FIGURE.findall(c)}
|
|
1040
|
+
if not (said & set(figs)):
|
|
1041
|
+
return True
|
|
1042
|
+
return False
|
|
1019
1043
|
|
|
1020
1044
|
|
|
1021
1045
|
def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
|
|
@@ -1032,7 +1056,7 @@ def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
|
|
|
1032
1056
|
and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
|
|
1033
1057
|
or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
|
|
1034
1058
|
or stated_figure(rv) in figs)
|
|
1035
|
-
and not (role == "refute" and _qualified(stmt, *a))]
|
|
1059
|
+
and not (role == "refute" and _qualified(stmt, *a, figs, rv))]
|
|
1036
1060
|
# a count beyond the extreme's own figure that finds some refutes it, whatever else is bound beside it
|
|
1037
1061
|
pinned = [(m, a) for m, rv, a, role in rel if role == "refute" and _pinned(m, rv, set(figs))]
|
|
1038
1062
|
if pinned:
|
|
@@ -1061,7 +1085,7 @@ def _extreme_claims(stmt: str) -> list[tuple[str, frozenset]]:
|
|
|
1061
1085
|
end = next((x.start() for x in _CLAUSE.finditer(stmt, m.end())), len(stmt))
|
|
1062
1086
|
start = max((x.end() for x in _CLAUSE.finditer(stmt, 0, m.start())), default=0)
|
|
1063
1087
|
after = stmt[m.end():end]
|
|
1064
|
-
if _denied(after) or _DENIED_BEFORE.search(stmt[start:m.start()]):
|
|
1088
|
+
if _denied(after, w) or _DENIED_BEFORE.search(stmt[start:m.start()]):
|
|
1065
1089
|
continue
|
|
1066
1090
|
cop = _COPULA.search(after)
|
|
1067
1091
|
fig = _FIGURE.match(after, cop.end()) if cop else None
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
hypgraph — the command line.
|
|
3
3
|
|
|
4
4
|
hypgraph demo build the Broad Street (1854) teaching investigation into ./research.hypgraph
|
|
5
|
+
hypgraph demo dispute an honest mistake and a lie: the record checks one and contests the other
|
|
5
6
|
hypgraph serve open the HypGraph UI on http://127.0.0.1:8765
|
|
6
7
|
hypgraph init "Plant growth" --objective "…" --reviewers ada,lin
|
|
7
8
|
hypgraph add hypothesis statement="Blue light grows cress faster." predictions='["taller after 7 days"]'
|
|
@@ -351,19 +352,27 @@ def cmd_import(args) -> int:
|
|
|
351
352
|
|
|
352
353
|
|
|
353
354
|
def cmd_demo(args) -> int:
|
|
354
|
-
|
|
355
|
+
import importlib
|
|
356
|
+
|
|
357
|
+
from .demos import DEMOS
|
|
358
|
+
demo = importlib.import_module(DEMOS[args.name])
|
|
355
359
|
if Path(args.db).exists():
|
|
356
|
-
raise GraphError(f"{args.db} exists — choose another file: hypgraph demo --db
|
|
357
|
-
g, inv, story =
|
|
360
|
+
raise GraphError(f"{args.db} exists — choose another file: hypgraph demo {args.name} --db {args.name}.hypgraph")
|
|
361
|
+
g, inv, story = demo.build(path=args.db)
|
|
362
|
+
conduct = g.conduct(inv) if args.name == "dispute" else {}
|
|
358
363
|
if args.json:
|
|
359
|
-
_out(args, {"db": g.path, "inv": inv, "story": story})
|
|
364
|
+
_out(args, {"db": g.path, "inv": inv, "story": story, **({"conduct": conduct} if conduct else {})})
|
|
360
365
|
return 0
|
|
361
366
|
for s in story:
|
|
362
367
|
print(f"{s['at']} {s['actor']:<10} {s['text']}")
|
|
363
368
|
if "refused" in s:
|
|
364
369
|
print(" ✗ the record refused it:\n " +
|
|
365
370
|
s["refused"].replace("\n", "\n "))
|
|
366
|
-
|
|
371
|
+
if conduct:
|
|
372
|
+
print("\nWhat the record says of each author:")
|
|
373
|
+
for who, row in conduct.items():
|
|
374
|
+
print(f" {who:<10} {row['sentence']}")
|
|
375
|
+
print(f"\nBuilt {demo.TITLE!r} into {g.path}.\nExplore it: hypgraph --db {args.db} serve"
|
|
367
376
|
f" · hypgraph --db {args.db} report")
|
|
368
377
|
return 0
|
|
369
378
|
|
|
@@ -553,7 +562,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
553
562
|
"outside the hash chain")
|
|
554
563
|
s = cmd("import", cmd_import, "rebuild a graph file from a complete bundle")
|
|
555
564
|
s.add_argument("bundle")
|
|
556
|
-
cmd("demo", cmd_demo, "build
|
|
565
|
+
s = cmd("demo", cmd_demo, "build a teaching investigation: Broad Street (1854), or a disputed answer")
|
|
566
|
+
s.add_argument("name", nargs="?", default="broad-street", choices=["broad-street", "dispute"],
|
|
567
|
+
help="broad-street (default), or dispute: an honest mistake and a lie, told apart by the record")
|
|
557
568
|
cmd("schema", cmd_schema, "the node types and relations")
|
|
558
569
|
s = cmd("refusals", cmd_refusals, "the overclaims the record caught: per author, per rule, fixed or open")
|
|
559
570
|
s.add_argument("--inv", default="")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A disputed answer — two parties, one honest mistake, one lie, and the record that tells them apart.
|
|
3
|
+
|
|
4
|
+
A small road register answers one question: how long is the shortest route from Kelso to Holme? Two parties answer
|
|
5
|
+
it, and an independent reviewer reads what the record holds. The record does the counting: every route is
|
|
6
|
+
enumerated by the instrument (author ``lab``), so a claim about "the shortest" meets the count of routes shorter
|
|
7
|
+
than its figure.
|
|
8
|
+
|
|
9
|
+
- **Ada** makes an honest mistake: she adds up the wrong route and says 30 km. The instrument finds 1 of the 4 routes
|
|
10
|
+
shorter, and the record refuses her claim (``COUNT_CONTRADICTS``). She corrects it to 29 km, which the count backs.
|
|
11
|
+
- **Mallory** argues in bad faith: the register's distances are in miles, not kilometres, so no answer stands. She
|
|
12
|
+
cites the register's own units note as support; it says kilometres, and the record refuses her
|
|
13
|
+
(``SOURCE_CONTRADICTS``). She says it again, and is refused again. Then she drops the note and files the same lie
|
|
14
|
+
against Ada's answer, which the record can't check without the note, so it goes in.
|
|
15
|
+
- **Ruth**, the reviewer, checks both against the register: Ada's claim is checked, Mallory's fails.
|
|
16
|
+
|
|
17
|
+
Nobody labels anyone. What the Evidence view shows — Ada's claim *checked*, Mallory's *contested* — and what the
|
|
18
|
+
conduct log says — Ada corrected what she was shown, Mallory kept saying it — are derived from the record on read.
|
|
19
|
+
|
|
20
|
+
from hypgraph.demos import dispute
|
|
21
|
+
g, inv, story = dispute.build()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from itertools import permutations
|
|
27
|
+
|
|
28
|
+
from ..graph import Graph, GraphError
|
|
29
|
+
from ..policy import Policy
|
|
30
|
+
from .broad_street import DemoClock
|
|
31
|
+
|
|
32
|
+
TITLE = "A disputed answer: the shortest route from Kelso to Holme"
|
|
33
|
+
QUESTION = "How long is the shortest route by road from Kelso to Holme?"
|
|
34
|
+
|
|
35
|
+
# The register: each road, both ways, in kilometres (the note says so).
|
|
36
|
+
ROADS = [("Kelso", "Elstow", 22), ("Elstow", "Holme", 7), ("Kelso", "Lydney", 14), ("Lydney", "Elstow", 9),
|
|
37
|
+
("Lydney", "Corfe", 12), ("Corfe", "Holme", 19)]
|
|
38
|
+
NOTE = "Register note: every road can be driven in either direction, and every distance in the register is in kilometres."
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def routes(a: str = "Kelso", b: str = "Holme") -> list[tuple[list[str], int]]:
|
|
42
|
+
"""Every route from ``a`` to ``b`` that visits no town twice, with its length: what the instrument counts over."""
|
|
43
|
+
length = {frozenset((x, y)): d for x, y, d in ROADS}
|
|
44
|
+
towns = sorted({t for x, y, _ in ROADS for t in (x, y)} - {a, b})
|
|
45
|
+
out = []
|
|
46
|
+
for k in range(len(towns) + 1):
|
|
47
|
+
for mid in permutations(towns, k):
|
|
48
|
+
path = [a, *mid, b]
|
|
49
|
+
legs = [frozenset(p) for p in zip(path, path[1:])]
|
|
50
|
+
if all(leg in length for leg in legs):
|
|
51
|
+
out.append((path, sum(length[leg] for leg in legs)))
|
|
52
|
+
return sorted(out, key=lambda r: (r[1], r[0]))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build(g: Graph | None = None, path: str | None = None) -> tuple[Graph, str, list[dict]]:
|
|
56
|
+
"""Build the dispute. Returns (graph, investigation id, story): every step in order, with the refusals."""
|
|
57
|
+
clock = DemoClock("2026-11-02T09:00:00+00:00")
|
|
58
|
+
if g is None:
|
|
59
|
+
g = Graph(path, policy=Policy(reviewers={"ruth"}), clock=clock)
|
|
60
|
+
else:
|
|
61
|
+
g.clock = clock
|
|
62
|
+
story: list[dict] = []
|
|
63
|
+
all_routes = routes()
|
|
64
|
+
n = len(all_routes)
|
|
65
|
+
|
|
66
|
+
def say(who: str, what: str, node: dict | None = None) -> None:
|
|
67
|
+
story.append({"at": clock(), "actor": who, "text": what,
|
|
68
|
+
**({"node": node["id"], "code": node["code"]} if node else {})})
|
|
69
|
+
|
|
70
|
+
def refused(who: str, what: str, attempt) -> None:
|
|
71
|
+
try:
|
|
72
|
+
attempt()
|
|
73
|
+
except GraphError as e:
|
|
74
|
+
story.append({"at": clock(), "actor": who, "text": what, "refused": str(e)})
|
|
75
|
+
return
|
|
76
|
+
raise AssertionError(f"the demo expected a refusal: {what}")
|
|
77
|
+
|
|
78
|
+
curator, lab, ada, mallory, ruth = (g.author(x) for x in ("curator", "lab", "ada", "mallory", "ruth"))
|
|
79
|
+
|
|
80
|
+
# ── the question and the register ────────────────────────────────
|
|
81
|
+
inv = curator.investigation(TITLE, "Settle one disputed answer from the evidence everyone was given.",
|
|
82
|
+
scope=f"The road register below: {len(ROADS)} roads between five towns, and the "
|
|
83
|
+
f"{n} routes from Kelso to Holme that visit no town twice.",
|
|
84
|
+
reviewers=["ruth"])["id"]
|
|
85
|
+
q = curator.add("question", inv, text=QUESTION,
|
|
86
|
+
why="Two parties disagree about the answer; the register holds what is known.")
|
|
87
|
+
say("curator", "Opens the question.", q)
|
|
88
|
+
src = {}
|
|
89
|
+
for i, (x, y, d) in enumerate(ROADS, 1):
|
|
90
|
+
src[f"E{i}"] = curator.add("source", inv, citation=f"Road register, item E{i}.",
|
|
91
|
+
passage=f"Road register: the road between {x} and {y} is {d} km long.",
|
|
92
|
+
reliability="The register given to every party.")["id"]
|
|
93
|
+
src["E7"] = curator.add("source", inv, citation="Road register, item E7: the units note.", passage=NOTE,
|
|
94
|
+
reliability="The register given to every party.")["id"]
|
|
95
|
+
say("curator", f"Files the register: {len(ROADS)} roads and the units note (E1–E7).")
|
|
96
|
+
|
|
97
|
+
def count(figure: int, who: str) -> tuple[str, list[dict]]:
|
|
98
|
+
"""The instrument counts the routes shorter than ``figure`` and files it as a run and a result."""
|
|
99
|
+
k = sum(1 for _p, d in all_routes if d < figure)
|
|
100
|
+
run = lab.add("run", inv, kind="lab", state="succeeded",
|
|
101
|
+
name=f"Counted the routes from Kelso to Holme shorter than {figure} km, for {who}.",
|
|
102
|
+
note="Every route that visits no town twice, enumerated from the register.")
|
|
103
|
+
res = lab.add("result", inv, kind="computed", outcome="positive",
|
|
104
|
+
summary=f"{k} of {n} routes from Kelso to Holme are shorter than {figure} km.",
|
|
105
|
+
values={"route_length": float(figure),
|
|
106
|
+
"shorter_routes": {"k": k, "n": n, "figure": figure, "op": "<"}},
|
|
107
|
+
links=[{"rel": "produced", "from": run["id"]}])
|
|
108
|
+
meas = [{"key": "route_length", "name": "route length", "value": float(figure), "result": res["id"]},
|
|
109
|
+
{"key": "shorter_routes", "name": f"routes shorter than {figure} km", "k": k, "n": n,
|
|
110
|
+
"result": res["id"]}]
|
|
111
|
+
say("lab", f"Counts the routes shorter than {figure} km: {k} of {n}.", res)
|
|
112
|
+
return res["id"], meas
|
|
113
|
+
|
|
114
|
+
def cite(*eids: str, why: str) -> list[dict]:
|
|
115
|
+
return [{"rel": "supports", "from": src[e], "why": why} for e in eids]
|
|
116
|
+
|
|
117
|
+
# ── Ada: an honest mistake, shown and corrected ───────────────────
|
|
118
|
+
clock.set("2026-11-02", "10:00:00")
|
|
119
|
+
r30, m30 = count(30, "Ada")
|
|
120
|
+
claim30 = dict(statement="The shortest route from Kelso to Holme is 30 km (Kelso–Lydney–Elstow–Holme).",
|
|
121
|
+
kind="finding", scope=f"The {n} routes from Kelso to Holme in the register.",
|
|
122
|
+
measurements=m30,
|
|
123
|
+
links=[{"rel": "answers", "to": q["id"]},
|
|
124
|
+
*cite("E3", "E4", "E2", why="The three roads of the route, 14 + 9 + 7 km."),
|
|
125
|
+
{"rel": "supports", "from": r30, "why": "The instrument's count at 30 km."}])
|
|
126
|
+
refused("ada", "Says the shortest route is 30 km. The count finds a shorter one.",
|
|
127
|
+
lambda: ada.add("claim", inv, **claim30))
|
|
128
|
+
clock.set("2026-11-02", "10:20:00")
|
|
129
|
+
r29, m29 = count(29, "Ada")
|
|
130
|
+
cl1 = ada.add("claim", inv, kind="finding", measurements=m29,
|
|
131
|
+
statement=f"The shortest route from Kelso to Holme is 29 km (Kelso–Elstow–Holme); none of the {n} "
|
|
132
|
+
f"routes is shorter than 29 km.",
|
|
133
|
+
scope=f"The {n} routes from Kelso to Holme in the register.",
|
|
134
|
+
links=[{"rel": "answers", "to": q["id"]},
|
|
135
|
+
*cite("E1", "E2", why="The two roads of the route, 22 + 7 km."),
|
|
136
|
+
*cite("E7", why="The note: the distances are kilometres, and roads run both ways."),
|
|
137
|
+
{"rel": "supports", "from": r29, "why": "The instrument's count at 29 km."}])
|
|
138
|
+
say("ada", "Corrects it: 29 km, and none of the routes is shorter.", cl1)
|
|
139
|
+
|
|
140
|
+
# ── Mallory: the same lie, said again ─────────────────────────────
|
|
141
|
+
clock.set("2026-11-02", "11:00:00")
|
|
142
|
+
lie = dict(statement="The register's distances are recorded in miles, not kilometres, so Ada's answer for "
|
|
143
|
+
"the shortest route doesn't stand.",
|
|
144
|
+
kind="finding", scope="The road register.",
|
|
145
|
+
links=[{"rel": "answers", "to": q["id"]},
|
|
146
|
+
*cite("E7", why="The register's units note."),
|
|
147
|
+
{"rel": "challenges", "to": cl1["id"], "why": "Disputes the answer's units."}])
|
|
148
|
+
refused("mallory", "Says the distances are in miles, citing the units note as support. The note says kilometres.",
|
|
149
|
+
lambda: mallory.add("claim", inv, **lie))
|
|
150
|
+
clock.set("2026-11-02", "11:10:00")
|
|
151
|
+
refused("mallory", "Says it again.", lambda: mallory.add("claim", inv, **lie))
|
|
152
|
+
clock.set("2026-11-02", "11:20:00")
|
|
153
|
+
cl2 = mallory.add("claim", inv, kind="finding", scope="The road register.",
|
|
154
|
+
statement="The register's distances are recorded in miles, not kilometres, so Ada's answer for "
|
|
155
|
+
"the shortest route doesn't stand.",
|
|
156
|
+
links=[{"rel": "answers", "to": q["id"]},
|
|
157
|
+
*cite("E1", "E2", why="The road lengths the answer adds up."),
|
|
158
|
+
{"rel": "challenges", "to": cl1["id"], "why": "Disputes the answer's units."}])
|
|
159
|
+
say("mallory", "Drops the note and files the same claim against Ada's answer.", cl2)
|
|
160
|
+
|
|
161
|
+
# ── Ruth reads both against the register ──────────────────────────
|
|
162
|
+
clock.set("2026-11-02", "14:00:00")
|
|
163
|
+
ruth.review(cl1["id"], "checked", method="Added up every route in the register and read the units note.",
|
|
164
|
+
notes="29 km by Kelso–Elstow–Holme; no route is shorter; the note says kilometres.")
|
|
165
|
+
ruth.review(cl2["id"], "fail", method="Read the claim against the register's units note (E7).",
|
|
166
|
+
notes="E7 says every distance is in kilometres; nothing in the register says miles.")
|
|
167
|
+
say("ruth", "Checks Ada's claim and fails Mallory's.")
|
|
168
|
+
return g, inv, story
|
|
@@ -48,7 +48,8 @@ def next_experiments(g: "Graph", inv: str, limit: int = 10) -> dict:
|
|
|
48
48
|
"""Planned experiments ranked by how well they discriminate between live hypotheses, whether they can show their
|
|
49
49
|
own power, and cost — duplicates and leased work flagged. Plus live hypotheses that no experiment tests yet."""
|
|
50
50
|
s = g.snapshot()
|
|
51
|
-
|
|
51
|
+
order = _live_hypotheses(s, inv) # the record's order; the set is for membership only
|
|
52
|
+
live = set(order)
|
|
52
53
|
dup = {x for grp in duplicates(s, inv) for x in grp[1:]}
|
|
53
54
|
rows = []
|
|
54
55
|
for i, n in s.nodes.items():
|
|
@@ -69,7 +70,7 @@ def next_experiments(g: "Graph", inv: str, limit: int = 10) -> dict:
|
|
|
69
70
|
rows.sort(key=lambda r: (r["duplicate"], r["lease"] is not None, -r["score"]))
|
|
70
71
|
tested = {e["dst"] for x, n in s.nodes.items() if n["type"] == "experiment" and n["inv"] == inv
|
|
71
72
|
for e in s.out.get(x, []) if e["rel"] == "tests"}
|
|
72
|
-
gaps = [g.brief(s, h) for h in
|
|
73
|
+
gaps = [g.brief(s, h) for h in order if h not in tested]
|
|
73
74
|
return {"ranked": rows[:limit], "untested_hypotheses": gaps,
|
|
74
75
|
"rule": "score = hypotheses it separates (distinct expected outcomes) × 1.5 if it can show its own power "
|
|
75
76
|
"÷ log2(2 + cost in minutes); duplicates and leased work last"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The disputed-answer demo (hypgraph.demos.dispute): an honest mistake and a lie, told apart by the record alone.
|
|
2
|
+
|
|
3
|
+
The README and the website show its Evidence view (one claim checked, the other contested) and its conduct log. These
|
|
4
|
+
tests hold the demo to what those pages say: the refusals come from the record's own checks, the honest party ends
|
|
5
|
+
cleared and the liar ends named, and the whole record verifies."""
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from hypgraph import cli
|
|
9
|
+
from hypgraph.demos import DEMOS, dispute
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_the_routes_are_what_the_story_says():
|
|
13
|
+
assert [d for _p, d in dispute.routes()] == [29, 30, 45, 62]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_each_refusal_is_the_records_own_check():
|
|
17
|
+
_g, _inv, story = dispute.build()
|
|
18
|
+
refused = [(s["actor"], s["refused"]) for s in story if "refused" in s]
|
|
19
|
+
assert [a for a, _ in refused] == ["ada", "mallory", "mallory"]
|
|
20
|
+
assert "[COUNT_CONTRADICTS]" in refused[0][1] and "1 of 4" in refused[0][1]
|
|
21
|
+
assert all("[SOURCE_CONTRADICTS]" in r and "kilometres" in r for _a, r in refused[1:])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_the_reviewer_checks_one_answer_and_the_other_is_contested():
|
|
25
|
+
g, _inv, story = dispute.build()
|
|
26
|
+
codes = {s["code"]: s["node"] for s in story if s.get("code", "").startswith("CL")}
|
|
27
|
+
assert set(codes) == {"CL1", "CL2"}
|
|
28
|
+
assert g.get(codes["CL1"])["state"]["review"]["state"] == "checked"
|
|
29
|
+
assert g.get(codes["CL2"])["state"]["review"]["state"] == "contested"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_the_conduct_log_clears_the_honest_party_and_names_the_liar():
|
|
33
|
+
g, inv, _ = dispute.build()
|
|
34
|
+
c = g.conduct(inv)
|
|
35
|
+
assert c["ada"]["corrected"] == 1 and c["ada"]["reasserted"] == 0
|
|
36
|
+
assert not c["ada"]["still_saying"] and not c["ada"]["ends_false"]
|
|
37
|
+
assert c["mallory"]["by_source"] == 1 and c["mallory"]["reasserted"] == 2 and c["mallory"]["corrected"] == 0
|
|
38
|
+
assert c["mallory"]["still_saying"] and c["mallory"]["ends_false"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_the_record_verifies(tmp_path):
|
|
42
|
+
g, _inv, _ = dispute.build(path=str(tmp_path / "d.hypgraph"))
|
|
43
|
+
assert g.verify()["ok"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_hypgraph_demo_dispute(tmp_path, capsys):
|
|
47
|
+
assert "dispute" in DEMOS
|
|
48
|
+
db = tmp_path / "d.hypgraph"
|
|
49
|
+
assert cli.main(["--db", str(db), "demo", "dispute", "--json"]) == 0
|
|
50
|
+
out = json.loads(capsys.readouterr().out)
|
|
51
|
+
assert out["conduct"]["mallory"]["ends_false"] and not out["conduct"]["ada"]["ends_false"]
|
|
52
|
+
assert sum("refused" in s for s in out["story"]) == 3
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The next-experiments view lists untested hypotheses in the record's order, not the hash order of a set.
|
|
2
|
+
|
|
3
|
+
``next_experiments`` built ``untested_hypotheses`` by iterating a set of live hypothesis ids. String hashing is salted
|
|
4
|
+
per process, so the order changed from run to run, and ``hypgraph verify --standing`` failed on any bundle with two or
|
|
5
|
+
more untested hypotheses exported in another process: the bundle carried one order, the re-derivation another.
|
|
6
|
+
Invariants: the list follows the order the hypotheses were added; re-deriving a bundle's standing under any hash seed
|
|
7
|
+
agrees with what it carries."""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from hypgraph import Graph
|
|
14
|
+
|
|
15
|
+
TITLES = [f"Candidate {c} explains the readings." for c in "ABCDEFGH"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def record():
|
|
19
|
+
g = Graph()
|
|
20
|
+
inv = g.author("curator").investigation("Readings", "Which candidate explains the readings?")["id"]
|
|
21
|
+
a = g.author("a")
|
|
22
|
+
hs = [a.add("hypothesis", inv, statement=t, predictions=["it fits the held-out readings"])["id"] for t in TITLES]
|
|
23
|
+
a.add("experiment", inv, aim="Compare candidates A and B on the held-out readings.",
|
|
24
|
+
protocol="Fit both candidates to the same held-out readings.", success_criteria="A fit for each.",
|
|
25
|
+
method="least squares",
|
|
26
|
+
links=[{"rel": "tests", "to": hs[0], "data": {"expected": "A fits"}},
|
|
27
|
+
{"rel": "tests", "to": hs[1], "data": {"expected": "B fits"}}])
|
|
28
|
+
return g, inv, hs
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_untested_follow_the_record_order():
|
|
32
|
+
g, inv, hs = record()
|
|
33
|
+
got = [h["id"] for h in g.next_experiments(inv)["untested_hypotheses"]]
|
|
34
|
+
assert got == hs[2:]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_standing_verifies_under_any_hash_seed(tmp_path):
|
|
38
|
+
g, inv, _ = record()
|
|
39
|
+
f = tmp_path / "b.json"
|
|
40
|
+
f.write_text(json.dumps(g.export(inv)), encoding="utf-8")
|
|
41
|
+
for seed in ("0", "1", "2", "3", "4", "5"):
|
|
42
|
+
env = {**os.environ, "PYTHONHASHSEED": seed}
|
|
43
|
+
r = subprocess.run([sys.executable, "-m", "hypgraph", "verify", "--standing", str(f)], cwd=tmp_path, env=env,
|
|
44
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
45
|
+
assert r.returncode == 0, (seed, r.stdout, r.stderr)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""An extreme that quotes its own counterexample is still false.
|
|
2
|
+
|
|
3
|
+
"The shortest route is 30 km, since a count finds 1 of 77 shorter than 30 km" states the very count that refutes it.
|
|
4
|
+
A stated count used to exempt an extreme whenever its clause said "k of n ... than", so the counterexample, quoted,
|
|
5
|
+
made the claim pass. A count about a figure of the statement's own ("the shortest is 21 km; 1 of 77 are shorter than
|
|
6
|
+
26 km") is still not an exception to the extreme. Invariants: quoting the refuting count never turns a refusal into an
|
|
7
|
+
acceptance, with or without the figure on the result; the honest correction that quotes it is accepted; a count about
|
|
8
|
+
another figure stays out of the way."""
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from hypgraph import Graph, GraphError, Policy
|
|
12
|
+
|
|
13
|
+
SCOPE = "Every simple route between the two towns in the register (77 routes), distances in km."
|
|
14
|
+
WHY = "The route counts bear on the claim."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def outcome(values, statement, meas):
|
|
18
|
+
g = Graph(policy=Policy(reviewers={"critic"}))
|
|
19
|
+
inv = g.author("curator").investigation("Routes", "What is the shortest route between the two towns?")["id"]
|
|
20
|
+
lab = g.author("lab")
|
|
21
|
+
run = lab.add("run", inv, kind="lab", state="succeeded", name="Counted the routes.")
|
|
22
|
+
r = lab.add("result", inv, kind="computed", outcome="positive", summary="Counts of the routes.",
|
|
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": WHY}])
|
|
28
|
+
return []
|
|
29
|
+
except GraphError as e:
|
|
30
|
+
return [p["code"] for p in e.problems] or [str(e)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def shorter(k, figure=None, n=77):
|
|
34
|
+
v = {"k": k, "n": n}
|
|
35
|
+
return v if figure is None else {**v, "figure": figure, "op": "<"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def case(figure):
|
|
39
|
+
"""A route measured at 30 km and the instrument's count of routes shorter than it: 1 of 77. Named as the
|
|
40
|
+
instrument names it, with no figure in the name; the figure on the result or not."""
|
|
41
|
+
values = {"route_length": 30.0, "shorter_routes_connecting": shorter(1, figure)}
|
|
42
|
+
meas = [{"key": "route_length", "name": "route_length", "value": 30.0},
|
|
43
|
+
{"key": "shorter_routes_connecting", "name": "shorter routes connecting", "k": 1, "n": 77}]
|
|
44
|
+
return values, meas
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
FIGURE = pytest.mark.parametrize("figure", [None, 30], ids=["no figure on the result", "figure on the result"])
|
|
48
|
+
|
|
49
|
+
LIES = ["The shortest route is 30 km, since a count of every route that visits no town twice finds 1 of 77 shorter "
|
|
50
|
+
"than 30 km.",
|
|
51
|
+
"The shortest route is 30 km: 1 of 77 routes are shorter than 30 km.",
|
|
52
|
+
"The shortest route between the towns is 30 km, and 1 of 77 routes is shorter than 30 km."]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@FIGURE
|
|
56
|
+
@pytest.mark.parametrize("s", LIES)
|
|
57
|
+
def test_an_extreme_quoting_its_own_counterexample_is_refused(s, figure):
|
|
58
|
+
assert "COUNT_CONTRADICTS" in outcome(*case(figure)[:1], s, case(figure)[1]), s
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@FIGURE
|
|
62
|
+
def test_the_same_extreme_without_the_quote_is_refused(figure):
|
|
63
|
+
assert "COUNT_CONTRADICTS" in outcome(case(figure)[0], "The shortest route is 30 km.", case(figure)[1])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@FIGURE
|
|
67
|
+
@pytest.mark.parametrize("s", ["The shortest route is not 30 km, since a count of every route finds 1 of 77 shorter "
|
|
68
|
+
"than 30 km.",
|
|
69
|
+
"30 km is not the shortest route: 1 of 77 routes are shorter than 30 km."])
|
|
70
|
+
def test_the_correction_that_quotes_the_count_is_accepted(s, figure):
|
|
71
|
+
assert outcome(case(figure)[0], s, case(figure)[1]) == [], s
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_a_count_about_another_figure_is_not_an_exception_to_the_extreme():
|
|
75
|
+
"""The count beyond 26 the statement gives says nothing against "the shortest is 21"; the count at 21 backs it."""
|
|
76
|
+
s = "The shortest route is 21 km; 1 of 77 routes are shorter than 26 km."
|
|
77
|
+
values = {"route_length": 21.0, "shorter_than_21": shorter(0, 21), "shorter_than_26": shorter(1, 26)}
|
|
78
|
+
meas = [{"key": "route_length", "name": "route_length", "value": 21.0},
|
|
79
|
+
{"key": "shorter_than_21", "name": "routes shorter than 21 km", "k": 0, "n": 77},
|
|
80
|
+
{"key": "shorter_than_26", "name": "routes shorter than 26 km", "k": 1, "n": 77}]
|
|
81
|
+
assert "COUNT_CONTRADICTS" not in outcome(values, s, meas)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Two readings the quoted-count exemption used to hide: once a quoted count no longer excuses an extreme, an honest
|
|
85
|
+
# sentence must not be read as claiming one.
|
|
86
|
+
|
|
87
|
+
@FIGURE
|
|
88
|
+
@pytest.mark.parametrize("s", ["The shortest route is therefore not 30 km: 1 of 77 routes are shorter than 30 km.",
|
|
89
|
+
"1 of 77 routes are shorter than 30 km, so 30 km is therefore not the shortest route.",
|
|
90
|
+
"A count finds 1 of 77 routes shorter than 30 km; the shortest route is thus not 30 km."])
|
|
91
|
+
def test_a_denial_with_an_adverb_is_a_correction(s, figure):
|
|
92
|
+
assert outcome(case(figure)[0], s, case(figure)[1]) == [], s
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@FIGURE
|
|
96
|
+
@pytest.mark.parametrize("s", ["The shortest route is at most 30 km: 1 of 77 routes are shorter than 30 km.",
|
|
97
|
+
"The shortest route is 30 km or less; 1 of 77 routes are shorter than 30 km."])
|
|
98
|
+
def test_a_bound_in_the_extremes_own_direction_is_not_its_value(s, figure):
|
|
99
|
+
"""At most 30 km, with a route shorter than 30 found, is true: the bound is not a claim that 30 is the shortest."""
|
|
100
|
+
assert "COUNT_CONTRADICTS" not in outcome(case(figure)[0], s, case(figure)[1]), s
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@FIGURE
|
|
104
|
+
def test_a_bound_the_other_way_still_claims_its_figure(figure):
|
|
105
|
+
"""At least 30 km, with a route shorter than 30 found, is false."""
|
|
106
|
+
s = "The shortest route is at least 30 km."
|
|
107
|
+
assert "COUNT_CONTRADICTS" in outcome(case(figure)[0], s, case(figure)[1])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|