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.
Files changed (125) hide show
  1. {hypgraph-0.7.24 → hypgraph-0.7.26}/CHANGELOG.md +46 -0
  2. {hypgraph-0.7.24 → hypgraph-0.7.26}/PKG-INFO +33 -6
  3. {hypgraph-0.7.24 → hypgraph-0.7.26}/README.md +32 -5
  4. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/PLAN.md +1 -1
  5. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/RELEASE-READINESS.md +32 -0
  6. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/__init__.py +1 -1
  7. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/checks.py +263 -22
  8. hypgraph-0.7.26/tests/test_counts_that_bear.py +160 -0
  9. hypgraph-0.7.26/tests/test_what_a_count_is_of.py +152 -0
  10. {hypgraph-0.7.24 → hypgraph-0.7.26}/.gitignore +0 -0
  11. {hypgraph-0.7.24 → hypgraph-0.7.26}/AGENTS.md +0 -0
  12. {hypgraph-0.7.24 → hypgraph-0.7.26}/CITATION.cff +0 -0
  13. {hypgraph-0.7.24 → hypgraph-0.7.26}/DISCLAIMER.md +0 -0
  14. {hypgraph-0.7.24 → hypgraph-0.7.26}/LICENSE +0 -0
  15. {hypgraph-0.7.24 → hypgraph-0.7.26}/NOTICE +0 -0
  16. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  17. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  18. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/profile-workflows-plan.md +0 -0
  19. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/review-2026-10-24.md +0 -0
  20. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/site-plan.md +0 -0
  21. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/spec.md +0 -0
  22. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/verification.md +0 -0
  24. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/README.md +0 -0
  25. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/knowledge.md +0 -0
  26. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/optimization.md +0 -0
  27. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/scientific.md +0 -0
  28. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/software.md +0 -0
  29. {hypgraph-0.7.24 → hypgraph-0.7.26}/docs/workflows/trading.md +0 -0
  30. {hypgraph-0.7.24 → hypgraph-0.7.26}/examples/frameworks/README.md +0 -0
  31. {hypgraph-0.7.24 → hypgraph-0.7.26}/pyproject.toml +0 -0
  32. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/blind.py +0 -0
  34. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/bundle.py +0 -0
  35. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/demos/linked.py +0 -0
  40. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/language.py +0 -0
  43. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/mcp.py +0 -0
  44. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/plan.py +0 -0
  45. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/policy.py +0 -0
  46. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/profiles.py +0 -0
  47. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/references.py +0 -0
  48. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/refusals.py +0 -0
  49. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/report_check.py +0 -0
  50. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/schema.py +0 -0
  51. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/server.py +0 -0
  52. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/skill/SKILL.md +0 -0
  53. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/stats.py +0 -0
  54. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/store.py +0 -0
  55. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/__init__.py +0 -0
  56. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/atlas.css +0 -0
  57. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/atlas.js +0 -0
  58. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.css +0 -0
  59. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  60. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.js +0 -0
  61. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.svg +0 -0
  62. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/index.html +0 -0
  63. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  64. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  65. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  66. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  67. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  68. {hypgraph-0.7.24 → hypgraph-0.7.26}/src/hypgraph/workflow.py +0 -0
  69. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_accept_work_kind.py +0 -0
  70. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_anchoring.py +0 -0
  71. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_blind.py +0 -0
  72. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_blind_oracles.py +0 -0
  73. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_bound_support.py +0 -0
  74. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_bundle_telemetry.py +0 -0
  75. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_cancel_releases_lease.py +0 -0
  76. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_cited_and_failed_support.py +0 -0
  77. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_contradictions.py +0 -0
  78. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_coord.py +0 -0
  79. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_d10.py +0 -0
  80. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_direction_and_units.py +0 -0
  81. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_engine.py +0 -0
  82. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_engine_fixes.py +0 -0
  83. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_framework_examples.py +0 -0
  84. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_i18n.py +0 -0
  85. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_incremental.py +0 -0
  86. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_link_translate.py +0 -0
  87. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_linked_records.py +0 -0
  88. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_log_and_check.py +0 -0
  89. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_measure_questions.py +0 -0
  90. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_opposed_answers.py +0 -0
  91. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_option_lead.py +0 -0
  92. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_order_detail.py +0 -0
  93. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_profiles.py +0 -0
  94. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_profiles_041.py +0 -0
  95. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_quantifiers_and_identity.py +0 -0
  96. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_questions_not_assertions.py +0 -0
  97. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_references.py +0 -0
  98. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_refusal_friction.py +0 -0
  99. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_refusal_results.py +0 -0
  100. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_refusals.py +0 -0
  101. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_relation_words.py +0 -0
  102. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_report_answers.py +0 -0
  103. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_rerunnable.py +0 -0
  104. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_restates_scale.py +0 -0
  105. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_server_auth.py +0 -0
  106. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_server_hosts.py +0 -0
  107. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_shape_refusals.py +0 -0
  108. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_stats_not_backtests.py +0 -0
  109. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_stats_values.py +0 -0
  110. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_surfaces.py +0 -0
  111. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_telemetry_stream.py +0 -0
  112. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_trust_boundary.py +0 -0
  113. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_ui_app.py +0 -0
  114. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_ui_engine.py +0 -0
  115. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_uncorrected_effect.py +0 -0
  116. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_unnamed_inputs.py +0 -0
  117. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_complete.py +0 -0
  118. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_lifecycle.py +0 -0
  119. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_scope.py +0 -0
  120. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_scoped_views.py +0 -0
  121. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_trust.py +0 -0
  122. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_verify_views.py +0 -0
  123. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_versions_not_figures.py +0 -0
  124. {hypgraph-0.7.24 → hypgraph-0.7.26}/tests/test_vocabulary_friction.py +0 -0
  125. {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.24
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
- Agents doing research write fluent summaries. Some of the numbers in them match no result. A clean red-team run of
44
- 50 prompts becomes "the model never leaks". A search too weak to find anything becomes "ruled out", and three copies
45
- of one result get counted as three lines of evidence. Then other claims, decisions and deployments get built on top.
46
- HypGraph is the record agents write into instead. It checks every claim as it's written, does the arithmetic the
47
- writer skipped, and refuses:
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
- Agents doing research write fluent summaries. Some of the numbers in them match no result. A clean red-team run of
12
- 50 prompts becomes "the model never leaks". A search too weak to find anything becomes "ruled out", and three copies
13
- of one result get counted as three lines of evidence. Then other claims, decisions and deployments get built on top.
14
- HypGraph is the record agents write into instead. It checks every claim as it's written, does the arithmetic the
15
- writer skipped, and refuses:
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.24 (2026-11-01)
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.24"
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
- def _absolute_targets(stmt: str, w: str) -> list[tuple[frozenset, frozenset]]:
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
- out: list[tuple[frozenset, frozenset]] = []
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
- end = next((x.start() for x in _SUBCLAUSE.finditer(stmt, m.end())), len(stmt))
443
- for fig, c in _clause_comparisons(stmt[m.end():end]):
444
- out.append((frozenset({fig}), frozenset() if c is None else frozenset({_negated(c) if every else c})))
445
- return out or _extreme_targets(stmt) or [(frozenset(), frozenset())]
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
- count, or a claim, whose comparison can't be told is read as before: none or all backs, some refutes."""
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 cs is None and comps:
548
+ if comps and not cs:
465
549
  return None
466
- if not cs or not comps:
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 figs, comps in _absolute_targets(stmt, w):
506
- if (figs, comps, _ALL.match(w) is None) not in seen:
507
- seen.add((figs, comps, _ALL.match(w) is None))
508
- out += [p for p in _absolute(stmt, scope, body, bound, w, words, set(figs), set(comps))
509
- if p.text() not in {q.text() for q in out}]
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)