hypgraph 0.7.25__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.25 → hypgraph-0.7.26}/CHANGELOG.md +21 -0
  2. {hypgraph-0.7.25 → hypgraph-0.7.26}/PKG-INFO +33 -6
  3. {hypgraph-0.7.25 → hypgraph-0.7.26}/README.md +32 -5
  4. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/PLAN.md +1 -1
  5. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/RELEASE-READINESS.md +15 -0
  6. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/__init__.py +1 -1
  7. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/checks.py +148 -7
  8. hypgraph-0.7.26/tests/test_what_a_count_is_of.py +152 -0
  9. {hypgraph-0.7.25 → hypgraph-0.7.26}/.gitignore +0 -0
  10. {hypgraph-0.7.25 → hypgraph-0.7.26}/AGENTS.md +0 -0
  11. {hypgraph-0.7.25 → hypgraph-0.7.26}/CITATION.cff +0 -0
  12. {hypgraph-0.7.25 → hypgraph-0.7.26}/DISCLAIMER.md +0 -0
  13. {hypgraph-0.7.25 → hypgraph-0.7.26}/LICENSE +0 -0
  14. {hypgraph-0.7.25 → hypgraph-0.7.26}/NOTICE +0 -0
  15. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  16. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  17. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/profile-workflows-plan.md +0 -0
  18. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/review-2026-10-24.md +0 -0
  19. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/site-plan.md +0 -0
  20. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/spec.md +0 -0
  21. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/ui-plan.md +0 -0
  22. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/verification.md +0 -0
  23. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/workflows/README.md +0 -0
  24. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/workflows/knowledge.md +0 -0
  25. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/workflows/optimization.md +0 -0
  26. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/workflows/scientific.md +0 -0
  27. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/workflows/software.md +0 -0
  28. {hypgraph-0.7.25 → hypgraph-0.7.26}/docs/workflows/trading.md +0 -0
  29. {hypgraph-0.7.25 → hypgraph-0.7.26}/examples/frameworks/README.md +0 -0
  30. {hypgraph-0.7.25 → hypgraph-0.7.26}/pyproject.toml +0 -0
  31. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/__main__.py +0 -0
  32. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/blind.py +0 -0
  33. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/bundle.py +0 -0
  34. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/cli.py +0 -0
  35. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/coord.py +0 -0
  36. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/demos/__init__.py +0 -0
  37. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/demos/broad_street.py +0 -0
  38. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/demos/linked.py +0 -0
  39. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/derive.py +0 -0
  40. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/graph.py +0 -0
  41. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/language.py +0 -0
  42. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/mcp.py +0 -0
  43. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/plan.py +0 -0
  44. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/policy.py +0 -0
  45. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/profiles.py +0 -0
  46. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/references.py +0 -0
  47. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/refusals.py +0 -0
  48. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/report_check.py +0 -0
  49. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/schema.py +0 -0
  50. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/server.py +0 -0
  51. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/skill/SKILL.md +0 -0
  52. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/stats.py +0 -0
  53. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/store.py +0 -0
  54. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/__init__.py +0 -0
  55. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/atlas.css +0 -0
  56. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/atlas.js +0 -0
  57. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.css +0 -0
  58. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  59. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.js +0 -0
  60. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/hypgraph.svg +0 -0
  61. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/index.html +0 -0
  62. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  63. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  64. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  65. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  66. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  67. {hypgraph-0.7.25 → hypgraph-0.7.26}/src/hypgraph/workflow.py +0 -0
  68. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_accept_work_kind.py +0 -0
  69. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_anchoring.py +0 -0
  70. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_blind.py +0 -0
  71. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_blind_oracles.py +0 -0
  72. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_bound_support.py +0 -0
  73. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_bundle_telemetry.py +0 -0
  74. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_cancel_releases_lease.py +0 -0
  75. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_cited_and_failed_support.py +0 -0
  76. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_contradictions.py +0 -0
  77. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_coord.py +0 -0
  78. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_counts_that_bear.py +0 -0
  79. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_d10.py +0 -0
  80. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_direction_and_units.py +0 -0
  81. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_engine.py +0 -0
  82. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_engine_fixes.py +0 -0
  83. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_framework_examples.py +0 -0
  84. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_i18n.py +0 -0
  85. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_incremental.py +0 -0
  86. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_link_translate.py +0 -0
  87. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_linked_records.py +0 -0
  88. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_log_and_check.py +0 -0
  89. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_measure_questions.py +0 -0
  90. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_opposed_answers.py +0 -0
  91. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_option_lead.py +0 -0
  92. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_order_detail.py +0 -0
  93. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_profiles.py +0 -0
  94. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_profiles_041.py +0 -0
  95. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_quantifiers_and_identity.py +0 -0
  96. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_questions_not_assertions.py +0 -0
  97. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_references.py +0 -0
  98. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_refusal_friction.py +0 -0
  99. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_refusal_results.py +0 -0
  100. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_refusals.py +0 -0
  101. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_relation_words.py +0 -0
  102. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_report_answers.py +0 -0
  103. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_rerunnable.py +0 -0
  104. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_restates_scale.py +0 -0
  105. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_server_auth.py +0 -0
  106. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_server_hosts.py +0 -0
  107. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_shape_refusals.py +0 -0
  108. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_stats_not_backtests.py +0 -0
  109. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_stats_values.py +0 -0
  110. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_surfaces.py +0 -0
  111. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_telemetry_stream.py +0 -0
  112. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_trust_boundary.py +0 -0
  113. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_ui_app.py +0 -0
  114. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_ui_engine.py +0 -0
  115. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_uncorrected_effect.py +0 -0
  116. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_unnamed_inputs.py +0 -0
  117. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_verify_complete.py +0 -0
  118. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_verify_lifecycle.py +0 -0
  119. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_verify_scope.py +0 -0
  120. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_verify_scoped_views.py +0 -0
  121. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_verify_trust.py +0 -0
  122. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_verify_views.py +0 -0
  123. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_versions_not_figures.py +0 -0
  124. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_vocabulary_friction.py +0 -0
  125. {hypgraph-0.7.25 → hypgraph-0.7.26}/tests/test_workflows.py +0 -0
@@ -1,5 +1,26 @@
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
+
3
24
  ## 0.7.25 — 2026-11-01: which counts bear on a claim, joined comparisons, boundaries in names
4
25
 
5
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.25
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.25 (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
@@ -166,6 +166,21 @@ was taken from a changelog without being checked against the code.*
166
166
  > suite is 574 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Kinds are read from a plural noun
167
167
  > and the words before it, a heuristic; the typed claim layer the audits recommend is not built. Released as 0.7.25.
168
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
+
169
184
  ## Recommendation: CONDITIONAL GO
170
185
 
171
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.25"
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"):
@@ -228,11 +229,43 @@ def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]
228
229
  f"{m['result']} counted '{m['key']}' with {op}",
229
230
  fix=f"name the count after the comparison it made ({op})"))
230
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
231
254
  if not bad:
232
255
  good.append((m, rv))
233
256
  return good, problems
234
257
 
235
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
+
236
269
  def _exposure_note(bound_rate: float, body: dict) -> str:
237
270
  x = body.get("exposure")
238
271
  if not isinstance(x, (int, float)) or x <= 0:
@@ -439,6 +472,9 @@ def _clause_comparisons(clause: str) -> list[tuple[float, tuple[str, int, bool]
439
472
  return out
440
473
 
441
474
 
475
+ # a target group whose parts are joined by both "and" and "or" (``_target_groups``)
476
+ MIXED = (frozenset(), frozenset({("mixed", 0, False)}))
477
+
442
478
  # "and" or "or" before a comparison with no subject of its own: "shorter than 20 km and longer than 30 km"
443
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|"
444
480
  r"beyond\b|exceed)", re.I)
@@ -464,7 +500,11 @@ def _target_groups(stmt: str, w: str) -> list[list[tuple[frozenset, frozenset]]]
464
500
  falsifies the claim ("no route is shorter than 20 km or longer than 30 km"; "every route is longer than 10 km and
465
501
  shorter than 30 km"), and one group where only an exception to both does ("no route is shorter than 20 km and
466
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
467
- is neither) (audit HG-024-03)."""
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)."""
468
508
  out: list[list[tuple[frozenset, frozenset]]] = []
469
509
  every = bool(_ALL.match(w)) and not _names_stated_count(stmt, w)
470
510
  for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
@@ -473,7 +513,9 @@ def _target_groups(stmt: str, w: str) -> list[list[tuple[frozenset, frozenset]]]
473
513
  for fig, c in _clause_comparisons(clause)]
474
514
  ands = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\band\b", clause, re.I))
475
515
  ors = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\bor\b", clause, re.I))
476
- if len(targets) > 1 and ands != ors and (ands != every):
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):
477
519
  out.append(targets)
478
520
  else:
479
521
  out += [[x] for x in targets]
@@ -482,7 +524,7 @@ def _target_groups(stmt: str, w: str) -> list[list[tuple[frozenset, frozenset]]]
482
524
 
483
525
  def _absolute_targets(stmt: str, w: str) -> list[tuple[frozenset, frozenset]]:
484
526
  """Every thing an absolute rules out, each on its own (``_target_groups`` says which go together)."""
485
- return [x for group in _target_groups(stmt, w) for x in group]
527
+ return [x for group in _target_groups(stmt, w) for x in group if x is not MIXED]
486
528
 
487
529
 
488
530
  def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
@@ -552,6 +594,77 @@ def _modifiers(words: list[str], i: int) -> set[str]:
552
594
  return out
553
595
 
554
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
+
555
668
  def _subject_fit(stmt: str, m: dict) -> tuple[bool, bool]:
556
669
  """Is a count about what a claim is about? Returns (covers, apart). A count names what it counts in the plural
557
670
  ("cargo routes", "fitting selections"); where the statement uses that word too, the words right before it on each
@@ -559,7 +672,12 @@ def _subject_fit(stmt: str, m: dict) -> tuple[bool, bool]:
559
672
  statement names too: none of 3 cargo routes longer than 26 km says nothing of passenger routes, nor of every route
560
673
  (audit HG-024-02). They are apart if each names a kind the other doesn't (cargo, passenger); a count of all routes
561
674
  with one longer may still be about the passenger route. A count that names nothing the statement does is read as
562
- before."""
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."""
563
681
  said = re.findall(r"[a-z]+", stmt.lower())
564
682
  stems = [L.content_words(w) for w in said]
565
683
  about = set().union(*stems) if stems else set()
@@ -576,6 +694,21 @@ def _subject_fit(stmt: str, m: dict) -> tuple[bool, bool]:
576
694
  theirs = set().union(*(_modifiers(said, j) for j, s in enumerate(stems) if s & head)) - mine
577
695
  covers = covers and not kinds
578
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
579
712
  return covers, apart
580
713
 
581
714
 
@@ -598,6 +731,14 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
598
731
  if (tuple(group), _ALL.match(w) is None) in seen:
599
732
  continue
600
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
601
742
  got = [_absolute(stmt, scope, body, bound, w, words, set(figs), set(comps)) for figs, comps in group]
602
743
  if len(group) > 1: # ruled out together: none of one part means none of both
603
744
  if any(not g for g in got):
@@ -1477,7 +1618,7 @@ def claim_issues(s: Snap, body: dict, support: list[str], inv: str) -> list[Prob
1477
1618
  if kind in ("finding", "exclusion", "inconclusive") and not support:
1478
1619
  problems.append(Problem("EVIDENCE_MISSING", "link the evidence: at least one result, claim or source that "
1479
1620
  "supports it (links: {\"rel\": \"supports\", \"from\": <result id>, \"why\": …})"))
1480
- bound, bad = _bound(s, body.get("measurements") or [])
1621
+ bound, bad = _bound(s, body.get("measurements") or [], stmt)
1481
1622
  problems += bad
1482
1623
  clean = _DATE.sub(" ", _LABEL.sub(" ", stmt))
1483
1624
  plain, stat = derived_numbers(bound)
@@ -0,0 +1,152 @@
1
+ """What a count is of, what its key says it compared, and comparisons joined by both "and" and "or" (audit of 0.7.25,
2
+ HG-025-01..03).
3
+
4
+ A count of the cargo route is a count of cargo routes, singular or plural, and says nothing about passenger routes. A
5
+ count the lab keyed `route_speed_above_26` is a count of speeds, whatever the claimant names it. And "every route is
6
+ longer than 10 km and shorter than 30 km or shorter than 6 km" groups its parts in a way the words don't settle, so no
7
+ count of one part shows it true or false."""
8
+ import pytest
9
+
10
+ from hypgraph import Graph, GraphError, Policy
11
+
12
+ SCOPE = "The three surveyed routes, of 5, 20 and 25 km, against 6, 10, 26 and 30 km marks."
13
+
14
+
15
+ def record(values):
16
+ g = Graph(policy=Policy(reviewers={"critic"}))
17
+ inv = g.author("curator").investigation("Routes", "Which surveyed route is shortest?")["id"]
18
+ lab = g.author("lab")
19
+ run = lab.add("run", inv, kind="lab", state="succeeded", name="Measured the surveyed routes.")
20
+ r = lab.add("result", inv, kind="computed", outcome="positive", summary="Route counts.",
21
+ values=values, links=[{"rel": "produced", "from": run["id"]}])["id"]
22
+ return g, inv, r
23
+
24
+
25
+ def write(g, inv, statement, links, meas=(), scope=SCOPE):
26
+ return g.author("a").add("claim", inv, statement=statement, scope=scope, measurements=list(meas),
27
+ links=[{"rel": "supports", "from": x, "why": "Cited for the claim."} for x in links])
28
+
29
+
30
+ def codes(e) -> list[str]:
31
+ return [p["code"] for p in e.value.problems]
32
+
33
+
34
+ def count(r, key, name, k, n=3):
35
+ return {"name": name, "result": r, "key": key, "k": k, "n": n}
36
+
37
+
38
+ def typed(k, op=">", figure=26):
39
+ return {"k": k, "n": 3, "figure": figure, "op": op}
40
+
41
+
42
+ PASSENGER = ["No passenger route is longer than 26 km among the 3 routes.",
43
+ "Nothing is longer than 26 km among the 3 passenger routes."]
44
+
45
+
46
+ @pytest.mark.parametrize("statement", PASSENGER)
47
+ @pytest.mark.parametrize("key,name", [("cargo_route_longer_26", "cargo route longer than 26 km"),
48
+ ("cargo_longer_26", "cargo longer than 26 km"),
49
+ ("longer_26", "cargo route longer than 26 km")])
50
+ def test_a_count_of_another_kind_in_the_singular_does_not_back_the_claim(statement, key, name):
51
+ """HG-025-01: kinds were read only before a plural, so none of 3 "cargo route longer than 26 km" backed a claim
52
+ about passenger routes while the passenger count was 1 of 3."""
53
+ g, inv, r = record({key: typed(0), "passenger_routes_longer_26": typed(1)})
54
+ with pytest.raises(GraphError) as e:
55
+ write(g, inv, statement, [r], [count(r, key, name, 0)])
56
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
57
+
58
+
59
+ @pytest.mark.parametrize("statement", PASSENGER)
60
+ @pytest.mark.parametrize("key,name", [("passenger_route_longer_26", "passenger route longer than 26 km"),
61
+ ("passenger_routes_longer_26", "routes longer than 26 km"),
62
+ ("longer_26", "route longer than 26 km")])
63
+ def test_a_count_of_the_same_kind_or_of_all_backs_it(statement, key, name):
64
+ g, inv, r = record({key: typed(0)})
65
+ write(g, inv, statement, [r], [count(r, key, name, 0)])
66
+
67
+
68
+ def test_a_count_of_another_kind_in_the_singular_does_not_contradict():
69
+ g, inv, r = record({"cargo_route_longer_26": typed(1)})
70
+ with pytest.raises(GraphError) as e:
71
+ write(g, inv, PASSENGER[0], [r], [count(r, "cargo_route_longer_26", "cargo route longer than 26 km", 1)])
72
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
73
+
74
+
75
+ @pytest.mark.parametrize("statement", ["Nothing is longer than 26 km among the 3 routes.",
76
+ "No route is longer than 26 km among the 3 routes."])
77
+ @pytest.mark.parametrize("key", ["route_speed_above_26", "speed_above_26", "routes_faster_than_26"])
78
+ def test_a_name_cannot_turn_a_count_of_one_property_into_another(statement, key):
79
+ """HG-025-02: a count the lab keyed for speed, named "routes longer than 26 km" by the claimant, backed a claim
80
+ about length. The key says what was counted; a name that compares something else is refused."""
81
+ g, inv, r = record({key: typed(0)})
82
+ with pytest.raises(GraphError) as e:
83
+ write(g, inv, statement, [r], [count(r, key, "routes longer than 26 km", 0)])
84
+ assert "MEASUREMENT_MISMATCH" in codes(e) and "COUNT_CONTRADICTS" not in codes(e)
85
+
86
+
87
+ def test_a_name_cannot_turn_a_count_of_one_kind_into_another():
88
+ g, inv, r = record({"cargo_longer_26": typed(0), "passenger_longer_26": typed(1)})
89
+ with pytest.raises(GraphError) as e:
90
+ write(g, inv, PASSENGER[0], [r], [count(r, "cargo_longer_26", "passenger routes longer than 26 km", 0)])
91
+ assert "MEASUREMENT_MISMATCH" in codes(e)
92
+
93
+
94
+ @pytest.mark.parametrize("key,name", [("routes_longer_26", "routes longer than 26 km"),
95
+ ("route_length_above_26", "route length above 26 km"),
96
+ ("passenger_routes_longer_26", "routes longer than 26 km")])
97
+ def test_a_name_that_keeps_what_the_key_counted_is_accepted(key, name):
98
+ g, inv, r = record({key: typed(0)})
99
+ write(g, inv, PASSENGER[0], [r], [count(r, key, name, 0)])
100
+
101
+
102
+ MIXED = {"longer_10": typed(2, ">", 10), "shorter_30": typed(3, "<", 30), "shorter_6": typed(1, "<", 6)}
103
+ PARTS = [("longer_10", "routes longer than 10 km", 2), ("shorter_30", "routes shorter than 30 km", 3),
104
+ ("shorter_6", "routes shorter than 6 km", 1)]
105
+
106
+
107
+ @pytest.mark.parametrize("statement", [
108
+ "Every route is longer than 10 km and shorter than 30 km or shorter than 6 km among the 3 routes.",
109
+ "Every route is shorter than 6 km or longer than 10 km and shorter than 30 km among the 3 routes.",
110
+ "No route is longer than 10 km and shorter than 30 km or shorter than 6 km among the 3 routes."])
111
+ def test_and_with_or_in_one_clause_is_left_unresolved(statement):
112
+ """HG-025-03: with both "and" and "or" the parts were checked one by one, so routes of 5, 20 and 25 km, which make
113
+ the first two statements true, gave COUNT_CONTRADICTS. No count of a part settles such a clause either way."""
114
+ g, inv, r = record(MIXED)
115
+ with pytest.raises(GraphError) as e:
116
+ write(g, inv, statement, [r], [count(r, k, nm, kk) for k, nm, kk in PARTS])
117
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
118
+
119
+
120
+ def test_and_or_or_alone_still_reads_as_before():
121
+ g, inv, r = record(MIXED)
122
+ with pytest.raises(GraphError) as e:
123
+ write(g, inv, "Every route is longer than 10 km and shorter than 30 km among the 3 routes.", [r],
124
+ [count(r, k, nm, kk) for k, nm, kk in PARTS[:2]])
125
+ assert codes(e) == ["COUNT_CONTRADICTS"]
126
+ write(g, inv, "Every route is shorter than 30 km or shorter than 6 km among the 3 routes.", [r],
127
+ [count(r, "shorter_30", "routes shorter than 30 km", 3)])
128
+
129
+
130
+ @pytest.mark.parametrize("k", [0, 2])
131
+ def test_what_was_compared_is_not_what_was_counted(k):
132
+ """"fitting selections worth more" counts selections; "worth" says what they were compared on. The count speaks to
133
+ "nothing worth more fits" either way, as it did before."""
134
+ g, inv, r = record({"fitting_selections_worth_more": {"k": k, "n": 8, "figure": 120, "op": ">"}})
135
+ stmt = "The highest total value that fits within 37 kg is 120; nothing worth more fits among the 8 selections."
136
+ m = count(r, "fitting_selections_worth_more", "fitting selections worth more", k, 8)
137
+ if k:
138
+ with pytest.raises(GraphError) as e:
139
+ write(g, inv, stmt, [r], [m], scope="All 8 selections of the inventory within 37 kg.")
140
+ assert "COUNT_CONTRADICTS" in codes(e)
141
+ else:
142
+ write(g, inv, stmt, [r], [m], scope="All 8 selections of the inventory within 37 kg.")
143
+
144
+
145
+ def test_a_bound_in_the_statement_is_not_what_it_is_about():
146
+ """"the limit is at most 49 kg" bounds the claim; it doesn't make a count of selections a count of something else."""
147
+ g, inv, r = record({"fitting_selections_worth_more": {"k": 3, "n": 8, "figure": 117, "op": ">"}})
148
+ with pytest.raises(GraphError) as e:
149
+ write(g, inv, "The weight limit is at most 49 kg, and the highest total value that fits within it is 117.",
150
+ [r], [count(r, "fitting_selections_worth_more", "fitting selections worth more", 3, 8)],
151
+ scope="All 8 selections of the inventory within 49 kg.")
152
+ assert "COUNT_CONTRADICTS" in codes(e)
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