hypgraph 0.8.0__tar.gz → 0.8.1__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 (130) hide show
  1. {hypgraph-0.8.0 → hypgraph-0.8.1}/CHANGELOG.md +21 -0
  2. {hypgraph-0.8.0 → hypgraph-0.8.1}/PKG-INFO +1 -1
  3. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/PLAN.md +1 -1
  4. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/RELEASE-READINESS.md +15 -0
  5. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/spec.md +12 -5
  6. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/__init__.py +1 -1
  7. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/assertions.py +77 -21
  8. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/checks.py +24 -3
  9. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_assertions.py +127 -8
  10. {hypgraph-0.8.0 → hypgraph-0.8.1}/.gitignore +0 -0
  11. {hypgraph-0.8.0 → hypgraph-0.8.1}/AGENTS.md +0 -0
  12. {hypgraph-0.8.0 → hypgraph-0.8.1}/CITATION.cff +0 -0
  13. {hypgraph-0.8.0 → hypgraph-0.8.1}/DISCLAIMER.md +0 -0
  14. {hypgraph-0.8.0 → hypgraph-0.8.1}/LICENSE +0 -0
  15. {hypgraph-0.8.0 → hypgraph-0.8.1}/NOTICE +0 -0
  16. {hypgraph-0.8.0 → hypgraph-0.8.1}/README.md +0 -0
  17. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  18. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  19. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/profile-workflows-plan.md +0 -0
  20. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/review-2026-10-24.md +0 -0
  21. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/site-plan.md +0 -0
  22. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/verification.md +0 -0
  24. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/workflows/README.md +0 -0
  25. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/workflows/knowledge.md +0 -0
  26. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/workflows/optimization.md +0 -0
  27. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/workflows/scientific.md +0 -0
  28. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/workflows/software.md +0 -0
  29. {hypgraph-0.8.0 → hypgraph-0.8.1}/docs/workflows/trading.md +0 -0
  30. {hypgraph-0.8.0 → hypgraph-0.8.1}/examples/frameworks/README.md +0 -0
  31. {hypgraph-0.8.0 → hypgraph-0.8.1}/pyproject.toml +0 -0
  32. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/blind.py +0 -0
  34. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/bundle.py +0 -0
  35. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/demos/linked.py +0 -0
  40. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/language.py +0 -0
  43. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/mcp.py +0 -0
  44. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/plan.py +0 -0
  45. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/policy.py +0 -0
  46. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/profiles.py +0 -0
  47. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/references.py +0 -0
  48. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/refusals.py +0 -0
  49. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/report_check.py +0 -0
  50. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/schema.py +0 -0
  51. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/server.py +0 -0
  52. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/skill/SKILL.md +0 -0
  53. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/stats.py +0 -0
  54. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/store.py +0 -0
  55. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/__init__.py +0 -0
  56. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/atlas.css +0 -0
  57. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/atlas.js +0 -0
  58. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/hypgraph.css +0 -0
  59. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  60. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/hypgraph.js +0 -0
  61. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/hypgraph.svg +0 -0
  62. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/index.html +0 -0
  63. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  64. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  65. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  66. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  67. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  68. {hypgraph-0.8.0 → hypgraph-0.8.1}/src/hypgraph/workflow.py +0 -0
  69. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_accept_work_kind.py +0 -0
  70. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_anchoring.py +0 -0
  71. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_blind.py +0 -0
  72. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_blind_oracles.py +0 -0
  73. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_borrowed_words.py +0 -0
  74. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_bound_support.py +0 -0
  75. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_bundle_telemetry.py +0 -0
  76. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_cancel_releases_lease.py +0 -0
  77. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_cited_and_failed_support.py +0 -0
  78. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_contradictions.py +0 -0
  79. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_coord.py +0 -0
  80. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_counts_that_bear.py +0 -0
  81. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_d10.py +0 -0
  82. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_direction_and_units.py +0 -0
  83. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_engine.py +0 -0
  84. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_engine_fixes.py +0 -0
  85. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_figure_said_twice.py +0 -0
  86. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_framework_examples.py +0 -0
  87. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_i18n.py +0 -0
  88. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_incremental.py +0 -0
  89. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_link_translate.py +0 -0
  90. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_linked_records.py +0 -0
  91. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_log_and_check.py +0 -0
  92. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_measure_questions.py +0 -0
  93. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_opposed_answers.py +0 -0
  94. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_option_lead.py +0 -0
  95. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_order_detail.py +0 -0
  96. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_profiles.py +0 -0
  97. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_profiles_041.py +0 -0
  98. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_quantifiers_and_identity.py +0 -0
  99. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_questions_not_assertions.py +0 -0
  100. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_references.py +0 -0
  101. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_refusal_friction.py +0 -0
  102. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_refusal_results.py +0 -0
  103. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_refusals.py +0 -0
  104. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_relation_words.py +0 -0
  105. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_report_answers.py +0 -0
  106. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_rerunnable.py +0 -0
  107. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_restates_scale.py +0 -0
  108. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_server_auth.py +0 -0
  109. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_server_hosts.py +0 -0
  110. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_shape_refusals.py +0 -0
  111. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_stats_not_backtests.py +0 -0
  112. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_stats_values.py +0 -0
  113. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_surfaces.py +0 -0
  114. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_telemetry_stream.py +0 -0
  115. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_trust_boundary.py +0 -0
  116. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_typed_audits.py +0 -0
  117. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_ui_app.py +0 -0
  118. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_ui_engine.py +0 -0
  119. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_uncorrected_effect.py +0 -0
  120. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_unnamed_inputs.py +0 -0
  121. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_verify_complete.py +0 -0
  122. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_verify_lifecycle.py +0 -0
  123. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_verify_scope.py +0 -0
  124. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_verify_scoped_views.py +0 -0
  125. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_verify_trust.py +0 -0
  126. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_verify_views.py +0 -0
  127. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_versions_not_figures.py +0 -0
  128. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_vocabulary_friction.py +0 -0
  129. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_what_a_count_is_of.py +0 -0
  130. {hypgraph-0.8.0 → hypgraph-0.8.1}/tests/test_workflows.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1 — 2026-11-01: exact figures, units within their kind
4
+
5
+ An independent audit of 0.8.0 found two defects in the typed checks, both confirmed on the published wheel.
6
+
7
+ - **Exact figures (HG-080-01).** Containment of two conditions was decided by probing at each figure ± 1 and the
8
+ midpoints, in floats. Above 2**53 a figure + 1 is the figure, so x > 1e18 looked inside x <= 1e18, and a count of 0
9
+ above 1e18 supported "none below 1e18". Containment is now decided on the pieces the two figures cut the line into,
10
+ with no arithmetic on them, and figures keep their exact value (10**18 + 1 is not 1e18; it renders as itself).
11
+ - **Units within their kind (HG-080-02).** The unit table's kind was dropped, so `lb` (mass) matched `GBP` and
12
+ `sterling` (currency) through the shared name "pound". A unit is now its (kind, unit) readings: `lb` = `lbs` and
13
+ `GBP` = `sterling` still, never across kinds, and an ambiguous `pound` matches only another `pound`; the refusal says
14
+ what it could be and asks for `lb` or `GBP`.
15
+ - **`exactly` decided from bounds.** It was left unresolved unless its set was the counted one or its complement:
16
+ 4 of 4 above F now contradicts "exactly 4 below F". Over one population every verdict now equals enumeration of the
17
+ consistent worlds (sound and complete), and the test checks that.
18
+ - **An extreme stated as a value is two atoms.** In a typed investigation "the longest of the 4 routes is 26 km"
19
+ needs both "none above 26" and "some at 26 or more"; one of them is refused (`ASSERTION_MISSING`). An extreme stated
20
+ as a bound ("the slowest took at least 40 minutes") needs one.
21
+ - Tests: containment against exact rational probes over large, adjacent, subnormal and signed-zero figures; the audit's
22
+ cases through `_decide` and the write gate; units; extremes. The audit's own test file passes (13 of 13).
23
+
3
24
  ## 0.8.0 — 2026-11-01: typed claim assertions
4
25
 
5
26
  Eight independent audits (HG-020 → HG-028) found the same class of failure, each time in a new wording: to decide
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: The evidence layer for AI research agents: a hash-chained research record that checks every claim as it is written and derives how well each is known.
5
5
  Project-URL: Homepage, https://hypgraph.com
6
6
  Project-URL: Documentation, https://hypgraph.com/engineers/
@@ -109,7 +109,7 @@ claim's derived standing.
109
109
  Multi-writer sync between graphs (bundles cover sharing), authentication (the host's job; the HTTP server binds to
110
110
  loopback and is read-only unless started with `--writable`), and a hosted service.
111
111
 
112
- ## Status at 0.8.0 (2026-11-01)
112
+ ## Status at 0.8.1 (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
@@ -234,6 +234,21 @@ was taken from a changelog without being checked against the code.*
234
234
  > verified, and the record shows the atoms, not the sentence, as what it checked. Replaying the 1,900 recorded
235
235
  > adversarial entries changes none. #102 is merged with green CI (17 of 17 checks). Released as 0.8.0.
236
236
 
237
+ > **Audit of 0.8.0 (2026-11-01).** The first audit of the typed layer found its architecture sound (GO for a
238
+ > controlled typed-claims pilot) and two narrow defects in its helpers, both reproduced on the published wheel (its
239
+ > test file: 9 failed, the 4 controls passed; the wheel's SHA-256 matched the audit's). Containment was probed in
240
+ > floats at figure ± 1, which above 2**53 is the figure itself, so opposite conditions at 1e18 looked equal and an
241
+ > unsupported "none below 1e18" was supported (HG-080-01). And units dropped their kind, so `lb` matched `GBP` through
242
+ > the shared name "pound" (HG-080-02). Containment is now decided exactly on the pieces of the line, with figures kept
243
+ > as given; a unit is its (kind, unit) readings. Two related gaps closed with them: `exactly` was left open unless its
244
+ > set was the counted one (now decided from bounds, and every verdict equals enumeration over one population), and an
245
+ > extreme stated as a value now needs both its atoms.
246
+ >
247
+ > **Where that stands.** The boundary the audit names stays: prose is matched to atoms by reading words, so a missed
248
+ > part leaves prose unverified, never an atom verified; integrations should consume `asserts` and their verdicts, not
249
+ > the statement. Untyped mode keeps its documented limits. #104 is merged with green CI (17 of 17 checks). Released as
250
+ > 0.8.1.
251
+
237
252
  ## Recommendation: CONDITIONAL GO
238
253
 
239
254
  HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
@@ -290,22 +290,29 @@ smallest detectable difference uses 80% power at two-sided α = 0.05.
290
290
 
291
291
  **Typed claims.** An atom (§2) is decided by its bound count alone; no words are read. Let S_e = {x : x op_e f_e} (what
292
292
  was counted, k of n inside it, n − k outside) and S_a = {x : x op_a f_a} (what is asserted); P_e and P_a the
293
- populations. The property must equal the count's and the unit match it (spellings of one unit are one unit; nothing is
294
- converted); a stated `n` must equal the count's.
293
+ populations. The property must equal the count's and the unit match it: spellings of one unit are one unit within one
294
+ kind of unit (`lb` = `lbs`, `GBP` = `sterling`, never `lb` = `GBP`), a spelling that could be two kinds (`pound`:
295
+ mass or currency) matches only another that could be the same two, and nothing is converted. A stated `n` must equal
296
+ the count's. Figures are compared exactly, as given (an int or a float, at any magnitude), on the pieces of the line
297
+ the two figures cut it into — never by arithmetic on them.
295
298
 
296
299
  - `none` S_a: **supported** when a side of the count with 0 inside it contains S_a and P_a lies within P_e;
297
300
  **contradicted** when a side with more than 0 lies inside S_a and P_e lies within P_a.
298
301
  - `some` S_a: supported when a side with more than 0 lies inside S_a and P_e within P_a; contradicted when a side with
299
302
  0 contains S_a and P_a lies within P_e.
300
- - `all` S_a is `none` of the complement of S_a. `exactly` k needs the same population and the same set (or its
301
- complement, counted n − k), then compares k.
303
+ - `all` S_a is `none` of the complement of S_a.
304
+ - `exactly` k needs the same population. A side of the count lying inside S_a puts at least its count in S_a; a side
305
+ containing S_a, at most its count. k at both bounds is **supported**, k outside them **contradicted**.
306
+
307
+ Over one population these verdicts are complete as well as sound: supported or contradicted exactly when every set of
308
+ values consistent with the count agrees.
302
309
  - Anything else is **unresolved**, and names the field that blocked it.
303
310
 
304
311
  | Code | Refused when |
305
312
  |---|---|
306
313
  | `ASSERTION_CONTRADICTED` | an atom is false by its count. The refusal log tracks it like a count contradiction (§8). |
307
314
  | `ASSERTION_UNRESOLVED` | an atom its count can't settle: another property or unit, a population neither within nor containing the count's, a condition the count's doesn't decide, an untyped count, a malformed atom. |
308
- | `ASSERTION_MISSING` | in a `typed_claims` investigation, the statement has more parts that assert an absolute, an extreme or a count than the claim has atoms. Detection reads words only to ask for atoms; a missed word leaves prose unverified, never an atom verified. The refusal proposes atoms drafted from the statement and the claim's typed counts, which are checked like any other once written. |
315
+ | `ASSERTION_MISSING` | in a `typed_claims` investigation, the statement's parts that assert an absolute, an extreme or a count need more atoms than the claim has: one each, and two for an extreme stated as a value ("the longest is 26 km": none above, some at or above). Detection reads words only to ask for atoms; a missed word leaves prose unverified, never an atom verified. The refusal proposes atoms drafted from the statement and the claim's typed counts, which are checked like any other once written. |
309
316
 
310
317
  Atoms are checked in every investigation that has them. In a `typed_claims` investigation the word interpretation
311
318
  stands down: a measurement's name and key are not read for what was counted, and rule 5 (absolutes, with the
@@ -16,7 +16,7 @@ Standing is derived on every read, never stored; every write is an event in a SH
16
16
  their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
17
17
  """
18
18
 
19
- __version__ = "0.8.0"
19
+ __version__ = "0.8.1"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -18,7 +18,9 @@ steered one of those inferences. Here the instrument types what it counted and t
18
18
  population a path: ``route/passenger`` lies within ``route``, so a count of 0 over every route also settles
19
19
  "no passenger route"; a count of the passenger routes says nothing of the cargo routes.
20
20
  property an id, compared for equality ("length" is not "speed").
21
- unit compared for equality, spellings of one unit read as one ("km" = "kilometres"); never converted.
21
+ unit compared for equality, spellings of one unit read as one ("km" = "kilometres") within one kind of unit:
22
+ "lb" is mass and "GBP" currency, never the same; "pound", which could be either, matches only
23
+ another spelling that could be either. Never converted.
22
24
  n optional: the population's size as the claim states it; must be the count's n.
23
25
 
24
26
  Each atom is decided on its own, by sets on the number line — S_e = {x : x op_e figure_e} (what was counted) and
@@ -28,7 +30,13 @@ S_a = {x : x op_a figure_a} (what is asserted) — and the paths. A count of k i
28
30
  contradicted when a count above 0 is over a set inside S_a and a population inside the atom's
29
31
  some S_a the mirror: a count above 0 inside S_a supports it, a count of 0 containing S_a contradicts it
30
32
  all S_a none of the complement of S_a
31
- exactly the same set and the same population, then k compared
33
+ exactly the same population; a count over a set inside S_a puts at least that many in it, a count over a
34
+ set containing S_a at most that many: k inside those bounds, at both ends supported, outside them
35
+ contradicted (4 of 4 above F puts at most 0 below it, so "exactly 4 below F" is false)
36
+
37
+ Figures are compared exactly, at any magnitude: containment is decided on the pieces of the number line the two
38
+ figures cut it into (the figures themselves and the open intervals between), with no arithmetic on the figures — a
39
+ probe at figure + 1 is the figure itself above 2**53, which made x > 1e18 look inside x <= 1e18 (HG-080-01).
32
40
 
33
41
  Anything else is unresolved, and the verdict names the field that blocked it. One count can back two atoms only if
34
42
  each checks out against it on its own, so a true absolute can't lend anything to a false one (HG-028-01), and "all"
@@ -76,29 +84,56 @@ def prop(p) -> str | None:
76
84
 
77
85
 
78
86
  def _unit(u) -> frozenset | None:
79
- """A unit as the set of names it could be ("km" → {kilometre}); None when there's none."""
87
+ """A unit as the (kind, unit) pairs it could be ("km" → {(length, kilometre)}, "pound" → {(mass, pound),
88
+ (currency, pound)}); an unknown spelling is itself. None when there's none."""
80
89
  if u is None or (isinstance(u, str) and not u.strip()):
81
90
  return None
82
91
  from .checks import _UNIT_OF # one table of unit spellings, shared with the word checks
83
92
  w = str(u).strip().lower()
84
- return frozenset(c for _f, c in _UNIT_OF.get(w, ())) or frozenset({w})
93
+ return frozenset(_UNIT_OF.get(w, ())) or frozenset({("", w)})
85
94
 
86
95
 
87
96
  def same_unit(a, b) -> bool:
88
- ua, ub = _unit(a), _unit(b)
89
- return (ua is None and ub is None) or (ua is not None and ub is not None and bool(ua & ub))
97
+ """One unit: every reading of one is a reading of the other. "lb" = "lbs" and "GBP" = "sterling", but "lb" is
98
+ never "GBP" (HG-080-02), and an ambiguous "pound" is neither of them."""
99
+ return _unit(a) == _unit(b)
100
+
101
+
102
+ def ambiguous(u) -> list[str]:
103
+ """The kinds of unit an ambiguous spelling could be ("pound": currency, mass); [] when it is one unit."""
104
+ kinds = sorted({k for k, _c in _unit(u) or ()})
105
+ return kinds if len(kinds) > 1 else []
90
106
 
91
107
 
92
108
  def holds(x: float, op: str, f: float) -> bool:
93
109
  return {"<": x < f, "<=": x <= f, "=": x == f, "!=": x != f, ">=": x >= f, ">": x > f}[op]
94
110
 
95
111
 
112
+ def _pieces(*figures) -> list[tuple]:
113
+ """The number line cut at the figures: each figure as a point ("pt", x) and the open intervals between and beyond
114
+ them ("iv", lo, hi), None for an infinite end. Figures are compared as given (Python compares ints and floats
115
+ exactly); nothing is added to or averaged from them."""
116
+ pts = sorted(set(figures))
117
+ out: list[tuple] = [("iv", None, pts[0])]
118
+ for x, y in zip(pts, pts[1:]):
119
+ out += [("pt", x), ("iv", x, y)]
120
+ return out + [("pt", pts[-1]), ("iv", pts[-1], None)]
121
+
122
+
123
+ def _inside(piece: tuple, op: str, f) -> bool:
124
+ """Is a piece of the line inside {x : x op f}? f is one of the cuts, so an open interval lies wholly below or
125
+ wholly above it, and which side decides."""
126
+ if piece[0] == "pt":
127
+ return holds(piece[1], op, f)
128
+ _iv, _lo, hi = piece
129
+ below = hi is not None and hi <= f
130
+ return op in ("<", "<=", "!=") if below else op in (">", ">=", "!=")
131
+
132
+
96
133
  def subset(a: tuple, b: tuple) -> bool:
97
- """Is {x : x a[0] a[1]} inside {x : x b[0] b[1]}? Both sets are unions of intervals with ends at the two figures,
98
- so membership is constant on each piece between them: checking one point per piece decides it."""
99
- pts = sorted({float(a[1]), float(b[1])})
100
- probes = pts + [pts[0] - 1, pts[-1] + 1] + [(x + y) / 2 for x, y in zip(pts, pts[1:])]
101
- return all(holds(x, *b) for x in probes if holds(x, *a))
134
+ """Is {x : x a[0] a[1]} inside {x : x b[0] b[1]}? Both sets are unions of the pieces the two figures cut the line
135
+ into, so it is decided piece by piece, exactly, at any magnitude."""
136
+ return all(_inside(p, *b) for p in _pieces(a[1], b[1]) if _inside(p, *a))
102
137
 
103
138
 
104
139
  def same_set(a: tuple, b: tuple) -> bool:
@@ -122,7 +157,7 @@ def count_fields(rv) -> tuple[dict | None, str]:
122
157
  pop = path(rv["population"])
123
158
  if pop is None:
124
159
  return None, "the count's population is not a path (route/passenger)"
125
- return {"k": int(k), "n": int(n), "op": rv["op"], "figure": float(rv["figure"]), "population": pop,
160
+ return {"k": int(k), "n": int(n), "op": rv["op"], "figure": rv["figure"], "population": pop,
126
161
  "property": prop(rv["property"]), "unit": rv.get("unit")}, ""
127
162
 
128
163
 
@@ -171,9 +206,18 @@ def _cond(a: dict) -> str:
171
206
  return f"{a['property']} {SHOW_OP.get(a['op'], a['op'])} {fig}{u}"
172
207
 
173
208
 
209
+ def whole_form(f):
210
+ """A figure with its exact value, an integral float as an int (26.0 → 26, 1e18 → 10**18): 26 and 26.0 are one
211
+ figure. (Not ``f.is_integer()`` on an int — that is Python 3.12+.)"""
212
+ return f if isinstance(f, int) else int(f) if float(f).is_integer() else f
213
+
214
+
174
215
  def stats_fmt(v) -> str:
216
+ """A figure as it is: ints whole, floats exact (repr round-trips), so a rendered figure never hides a difference."""
217
+ if isinstance(v, int):
218
+ return str(v)
175
219
  v = float(v)
176
- return str(int(v)) if v.is_integer() else f"{v:g}"
220
+ return str(int(v)) if v.is_integer() and abs(v) < 1e15 else repr(v)
177
221
 
178
222
 
179
223
  def render(a: dict) -> str:
@@ -213,8 +257,11 @@ def _decide(a: dict, e: dict) -> tuple[str, str, str]:
213
257
  return "unresolved", "property", (f"the count is of {e['property']}, the assertion is about "
214
258
  f"{prop(a['property'])}")
215
259
  if not same_unit(a.get("unit"), e.get("unit")):
260
+ amb = next(((u, ambiguous(u)) for u in (a.get("unit"), e.get("unit")) if ambiguous(u)), None)
216
261
  return "unresolved", "unit", (f"the count is in {e.get('unit') or 'no unit'}, the assertion in "
217
- f"{a.get('unit') or 'no unit'}: state the figure in the count's unit")
262
+ f"{a.get('unit') or 'no unit'}: state the figure in the count's unit"
263
+ + (f"; '{amb[0]}' could be {' or '.join(amb[1])}, so name the unit "
264
+ "unambiguously (lb, GBP)" if amb else ""))
218
265
  if not (within(pa, pe) or within(pe, pa)):
219
266
  return "unresolved", "population", (f"the count is of {_pop(pe)}, the assertion is about {_pop(pa)}: "
220
267
  "neither lies within the other")
@@ -225,7 +272,7 @@ def _decide(a: dict, e: dict) -> tuple[str, str, str]:
225
272
  f"{_pop(pe)} ({e['n']}) doesn't give")
226
273
  # what was counted, both ways: k inside S_e and n - k outside it
227
274
  sides = [((e["op"], e["figure"]), e["k"]), ((COMPLEMENT[e["op"]], e["figure"]), e["n"] - e["k"])]
228
- q, t = a["quantifier"], (a["op"], float(a["figure"]))
275
+ q, t = a["quantifier"], (a["op"], a["figure"])
229
276
  got = _settle(q, t, sides, pa, pe, a)
230
277
  if got:
231
278
  return got
@@ -242,11 +289,18 @@ def _settle(q: str, t: tuple, sides: list, pa: tuple, pe: tuple, a: dict) -> tup
242
289
  if q == "exactly":
243
290
  if pa != pe:
244
291
  return None
292
+ lo, hi = 0, sum(c for _side, c in sides)
245
293
  for side, c in sides:
246
- if same_set(t, side):
247
- ok = int(a["k"]) == c
248
- return ("supported" if ok else "contradicted", "" if ok else "k",
249
- f"the count finds {c}" + ("" if ok else f", not {int(a['k'])}"))
294
+ if subset(side, t): # everything counted there is in the atom's set
295
+ lo = max(lo, c)
296
+ if subset(t, side): # the atom's set lies inside what was counted there
297
+ hi = min(hi, c)
298
+ want = int(a["k"])
299
+ found = f"the count finds {lo}" if lo == hi else f"the count puts {lo} to {hi} there"
300
+ if lo == hi == want:
301
+ return "supported", "", found
302
+ if want < lo or want > hi:
303
+ return "contradicted", "k", f"{found}, not {want}"
250
304
  return None
251
305
  neg = "that don't" if q == "all" else "that do"
252
306
  if q == "all": # everything meets it: nothing meets its complement
@@ -308,8 +362,10 @@ def key(a) -> str:
308
362
  if atom_shape(a, None)[0]:
309
363
  return json.dumps(a, sort_keys=True, default=str)[:300]
310
364
  u = _unit(a.get("unit"))
365
+ f = a["figure"]
366
+ fig = whole_form(f) # 26 and 26.0 are one figure, exactly
311
367
  return json.dumps([a["quantifier"], "/".join(path(a["population"]) or ()), prop(a["property"]), a["op"],
312
- float(a["figure"]), sorted(u) if u else None, a.get("n"), a.get("k")], default=str)
368
+ fig, sorted(u) if u else None, a.get("n"), a.get("k")], default=str)
313
369
 
314
370
 
315
371
  def numbers(body: dict) -> list[float]:
@@ -375,7 +431,7 @@ def draft(s: Snap, body: dict, inv: str = "") -> list[dict]:
375
431
  pool = [(i, e) for i, e in typed if e["n"] == n]
376
432
  for i, e in [x for x in pool if x[1]["figure"] in figs] or pool:
377
433
  add({"quantifier": "exactly", "population": _pop(e["population"]), "property": e["property"],
378
- "op": e["op"], "figure": int(e["figure"]) if e["figure"].is_integer() else e["figure"],
434
+ "op": e["op"], "figure": whole_form(e["figure"]),
379
435
  "k": k, "n": n, "measurement": i}, e)
380
436
  return out[:6]
381
437
 
@@ -1841,11 +1841,28 @@ def asserting_parts(stmt: str) -> list[str]:
1841
1841
  if L.interrogative(p):
1842
1842
  continue
1843
1843
  quantified = any(_clause_comparisons(m.group("rest"), after_no=True) for m in _QUANT_CMP.finditer(p))
1844
- if L.asserted(L.ABSOLUTE, p) or _extremes(p) or _STATED_COUNT.search(p) or quantified or _NO_NOUN_VERB.search(p):
1844
+ if L.asserted(L.ABSOLUTE, p) or _extremes(p) or _STATED_COUNT.search(p) or quantified or _NO_NOUN_VERB.search(p):
1845
1845
  out.append(p.strip())
1846
1846
  return out
1847
1847
 
1848
1848
 
1849
+ _STATED_FIGURE = re.compile(r"(?<![\w.])\d[\d,]*(?:\.\d+)?")
1850
+ _POPULATION_SIZE = re.compile(r"\b(?:of|among|across|between|from|in|all)\s+(?:the\s+|all\s+|its\s+|these\s+|those\s+)?"
1851
+ r"(\d[\d,]*)\b(?!\s*(?:%|percent|per\s*cent))", re.I)
1852
+
1853
+
1854
+ def atoms_needed(part: str) -> int:
1855
+ """How many atoms a part asks for: an extreme stated as a value ("the longest of the 4 routes is 26 km") is two —
1856
+ nothing above 26 and something at 26 or more; a figure it states only as a bound ("the slowest took at least 40
1857
+ minutes") is one. Over-counting asks for one more atom; it never marks one verified."""
1858
+ if not _extremes(part):
1859
+ return 1
1860
+ bounds = {f for f, _c in _clause_comparisons(part)}
1861
+ sizes = {m.start(1) for m in _POPULATION_SIZE.finditer(part)} # "of the 4 routes" is who, not a figure
1862
+ stated = {float(m.group(0).replace(",", "")) for m in _STATED_FIGURE.finditer(part) if m.start() not in sizes}
1863
+ return 2 if stated - bounds else 1
1864
+
1865
+
1849
1866
  def assertion_issues(s: Snap, body: dict, inv: str, stmt: str, typed: bool) -> list[Problem]:
1850
1867
  """The claim's atoms checked against their counts, and — in a typed investigation — whether the statement asserts
1851
1868
  more than its atoms do."""
@@ -1866,13 +1883,17 @@ def assertion_issues(s: Snap, body: dict, inv: str, stmt: str, typed: bool) -> l
1866
1883
  f"{v['field']}", words=[v["field"]]))
1867
1884
  if typed:
1868
1885
  parts = asserting_parts(stmt)
1869
- if len(parts) > len(atoms):
1886
+ needed = sum(atoms_needed(p) for p in parts)
1887
+ if needed > len(atoms):
1870
1888
  proposed = A.draft(s, body, inv) if not atoms else []
1871
1889
  hint = (" Proposed from the statement and its typed counts — check each before using it: "
1872
1890
  + json.dumps(proposed, separators=(",", ":"))[:900]) if proposed else ""
1873
1891
  out.append(Problem("ASSERTION_MISSING", f"this investigation checks claims as typed assertions: the "
1874
1892
  f"statement asserts {len(parts)} thing{'s' * (len(parts) != 1)} — "
1875
- + "; ".join(f"'{p[:80]}'" for p in parts[:4]) + f" — and the claim gives {len(atoms)} "
1893
+ + "; ".join(f"'{p[:80]}'" for p in parts[:4]) + f" — {needed} atom"
1894
+ + "s" * (needed != 1) + " in all" + (" (an extreme stated as a value is two: none "
1895
+ "above it, some at it or beyond)" if needed > len(parts) else "")
1896
+ + f", and the claim gives {len(atoms)} "
1876
1897
  "atom" + "s" * (len(atoms) != 1) + ". Add an atom for each to asserts: {quantifier "
1877
1898
  "(none | all | some | exactly), population, property, op, figure, unit, n, "
1878
1899
  "measurement}." + hint, fix="add an atom to asserts for each thing the statement "
@@ -7,7 +7,9 @@ is decided by sets on the number line and population paths. Invariants: a verdic
7
7
  never on the sentence around them; a true atom never lends anything to a false one; a count is whole."""
8
8
  import itertools
9
9
  import json
10
+ import math
10
11
  import random
12
+ from fractions import Fraction
11
13
 
12
14
  import pytest
13
15
 
@@ -75,16 +77,17 @@ def brute(q, op_a, f_a, op_e, f_e, k, n, kk=None):
75
77
  @pytest.mark.parametrize("op_a", A.OPS)
76
78
  @pytest.mark.parametrize("op_e", A.OPS)
77
79
  @pytest.mark.parametrize("f_a", [20, 26, 30])
78
- def test_the_interval_logic_never_decides_what_enumeration_leaves_open(q, op_a, op_e, f_a):
79
- """Sound: a supported or contradicted verdict is what every consistent world says. (Unresolved may be returned
80
- where enumeration could decide — the logic is conservative — but never the other way round.)"""
80
+ def test_the_interval_logic_decides_exactly_what_enumeration_decides(q, op_a, op_e, f_a):
81
+ """Sound and complete over one population: supported or contradicted exactly when every consistent world agrees,
82
+ unresolved exactly when they differ. (0.8.0 left 'exactly' open unless its set was the counted one or its
83
+ complement; it is now decided from bounds — HG-080.)"""
81
84
  n = 3
82
85
  for k in range(n + 1):
83
- kw = {"k": 1} if q == "exactly" else {}
84
- a = atom(q, op_a, f_a, **kw)
85
- got = A._decide(a, A.count_fields(cnt(k, 26, op_e, n))[0])[0]
86
- want = brute(q, op_a, f_a, op_e, 26, k, n, kw.get("k"))
87
- assert got == "unresolved" or got == want, (q, op_a, f_a, op_e, k, got, want)
86
+ for kk in (range(n + 1) if q == "exactly" else [None]):
87
+ kw = {"k": kk} if q == "exactly" else {}
88
+ got = A._decide(atom(q, op_a, f_a, **kw), A.count_fields(cnt(k, 26, op_e, n))[0])[0]
89
+ want = brute(q, op_a, f_a, op_e, 26, k, n, kk)
90
+ assert got == want, (q, op_a, f_a, op_e, k, kk, got, want)
88
91
 
89
92
 
90
93
  @pytest.mark.parametrize("q", ["none", "all", "some"])
@@ -388,3 +391,119 @@ def test_a_drafted_lie_is_still_refused():
388
391
  "measurements": [{"result": r, "key": "over_26"}]}
389
392
  d = A.draft(g.snapshot(), body, inv)
390
393
  assert [v["verdict"] for v in A.verify(g.snapshot(), {**body, "asserts": d}, inv)] == ["contradicted"]
394
+
395
+
396
+ # ── HG-080-01: exact at any magnitude ───────────────────────────────────────────────────────────────────────────
397
+
398
+ TRICKY = [10**16, 10**18, 10**24, 1e18, 10**18 + 1, -10**18, 2**53, 2**53 + 1, 5e-324, 0, -0.0, 1.0,
399
+ math.nextafter(1.0, 2.0), 26, 26.0]
400
+
401
+
402
+ def exact_subset(a, b):
403
+ """The reference: the probe method in exact rational arithmetic, where a figure ± 1 and a midpoint are always new
404
+ points (in floats, above 2**53 they are the figure itself)."""
405
+ fa, fb = Fraction(a[1]), Fraction(b[1])
406
+ pts = sorted({fa, fb})
407
+ probes = pts + [pts[0] - 1, pts[-1] + 1] + [(x + y) / 2 for x, y in zip(pts, pts[1:])]
408
+ return all(A.holds(x, b[0], fb) for x in probes if A.holds(x, a[0], fa))
409
+
410
+
411
+ @pytest.mark.parametrize("fa", TRICKY)
412
+ @pytest.mark.parametrize("fb", TRICKY)
413
+ def test_containment_is_exact_at_any_magnitude(fa, fb):
414
+ for oa in A.OPS:
415
+ for ob in A.OPS:
416
+ assert A.subset((oa, fa), (ob, fb)) == exact_subset((oa, fa), (ob, fb)), (oa, fa, ob, fb)
417
+
418
+
419
+ @pytest.mark.parametrize("f", [10**16, 10**18, 1e18, 10**24])
420
+ def test_a_large_figure_decides_as_a_small_one(f):
421
+ def e(k, op):
422
+ return A.count_fields(cnt(k, f, op))[0]
423
+ assert not A.subset((">", f), ("<=", f)) and not A.same_set((">", f), ("<", f))
424
+ assert A._decide(atom("none", "<", f, n=4), e(0, ">"))[0] == "unresolved" # 0 above F says nothing below
425
+ assert A._decide(atom("exactly", "<", f, n=4, k=4), e(4, ">"))[0] == "contradicted"
426
+ assert A._decide(atom("none", ">", f, n=4), e(0, ">"))[0] == "supported"
427
+ assert A._decide(atom("some", ">=", f), e(1, ">"))[0] == "supported"
428
+
429
+
430
+ def test_one_past_a_large_figure_is_another_figure():
431
+ """10**18 + 1 is not 1e18, though float() makes them one: nothing over 10**18 + 1 leaves room above 10**18."""
432
+ assert A._decide(atom("none", ">", 10**18, n=4), A.count_fields(cnt(0, 10**18 + 1))[0])[0] == "unresolved"
433
+ assert A._decide(atom("none", ">", 10**18 + 1, n=4), A.count_fields(cnt(0, 10**18))[0])[0] == "supported"
434
+
435
+
436
+ def test_a_large_figure_through_the_write_gate():
437
+ values = {"over": cnt(0, 10**18, ">", unit=None)}
438
+ codes = write(values, "No route is shorter than 1000000000000000000 among the 4 routes.",
439
+ [atom("none", "<", 10**18, unit=None, n=4)], ["over"])
440
+ assert "ASSERTION_UNRESOLVED" in codes, codes
441
+ values = {"over": cnt(4, 10**18, ">", unit=None)}
442
+ codes = write(values, "Exactly 4 of the 4 routes are shorter than 1000000000000000000.",
443
+ [atom("exactly", "<", 10**18, unit=None, n=4, k=4)], ["over"])
444
+ assert "ASSERTION_CONTRADICTED" in codes, codes
445
+
446
+
447
+ def test_a_figure_renders_as_it_is():
448
+ assert A.stats_fmt(10**18 + 1) == "1000000000000000001" and A.stats_fmt(26.0) == "26"
449
+ assert A.stats_fmt(math.nextafter(1.0, 2.0)) == "1.0000000000000002"
450
+ assert A.key(atom("none", figure=10**18)) != A.key(atom("none", figure=10**18 + 1))
451
+ assert A.key(atom("none", figure=26)) == A.key(atom("none", figure=26.0))
452
+
453
+
454
+ # ── HG-080-02: a unit is one only within its kind ───────────────────────────────────────────────────────────────
455
+
456
+ @pytest.mark.parametrize("a,b,same", [
457
+ ("lb", "lbs", True), ("GBP", "sterling", True), ("km", "kilometres", True), ("pounds", "pound", True),
458
+ ("widgets", "widgets", True), (None, None, True), (None, "", True),
459
+ ("lb", "GBP", False), ("lbs", "gbp", False), ("lb", "sterling", False), # mass is never currency
460
+ ("pound", "lb", False), ("pound", "GBP", False), # "pound" could be either
461
+ ("km", "miles", False), ("km", None, False),
462
+ ])
463
+ def test_units_are_one_only_within_their_kind(a, b, same):
464
+ assert A.same_unit(a, b) is same and A.same_unit(b, a) is same, (a, b)
465
+
466
+
467
+ def test_a_currency_count_cannot_back_a_mass_claim():
468
+ values = {"over": cnt(0, 40, ">", population="shipment", property="quantity", unit="GBP")}
469
+ a = atom("none", ">", 40, population="shipment", property="quantity", unit="lb", n=4)
470
+ v, field, _why = A._decide(a, A.count_fields(values["over"])[0])
471
+ assert (v, field) == ("unresolved", "unit")
472
+ codes = write(values, "No shipment weighs more than 40 lb among the 4 shipments.", [a], ["over"])
473
+ assert "ASSERTION_UNRESOLVED" in codes, codes
474
+
475
+
476
+ def test_an_ambiguous_unit_says_what_it_could_be():
477
+ v, field, why = A._decide(atom("none", unit="pound", n=4), A.count_fields(cnt(0, unit="lb"))[0])
478
+ assert (v, field) == ("unresolved", "unit") and "could be currency or mass" in why, why
479
+
480
+
481
+ # ── an extreme stated as a value is two atoms ───────────────────────────────────────────────────────────────────
482
+
483
+ EXTREME = {"over_26": cnt(0), "at_least_26": cnt(1, op=">=")}
484
+ BOTH = [atom("none", n=4), atom("some", ">=", m=1)]
485
+
486
+
487
+ def test_an_extreme_stated_as_a_value_needs_both_atoms():
488
+ stmt = "The longest of the 4 routes is 26 km."
489
+ assert write(EXTREME, stmt, BOTH, ["over_26", "at_least_26"]) == []
490
+ for one in (BOTH[:1], BOTH[1:]):
491
+ codes = write(EXTREME, stmt, one, ["over_26", "at_least_26"])
492
+ assert "ASSERTION_MISSING" in codes, (one, codes)
493
+
494
+
495
+ def test_an_extreme_stated_as_a_bound_needs_one_atom():
496
+ values = {"at_least_40": cnt(1, 40, ">=", property="time", unit="minutes")}
497
+ assert write(values, "The slowest of the 4 routes took at least 40 minutes.",
498
+ [atom("some", ">=", 40, property="time", unit="minutes")], ["at_least_40"]) == []
499
+
500
+
501
+ @pytest.mark.parametrize("part,needed", [
502
+ ("The longest of the 4 routes is 26 km.", 2), ("The highest score was 93.", 2),
503
+ ("The longest route, at 26 km, is the only one above 20 km.", 2),
504
+ ("The slowest of the 4 routes took at least 40 minutes.", 1), ("The highest of all 4 scores is at most 93.", 1),
505
+ ("No route is longer than 26 km.", 1),
506
+ ])
507
+ def test_how_many_atoms_a_part_asks_for(part, needed):
508
+ from hypgraph import checks as C
509
+ assert C.atoms_needed(part) == needed
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes