hypgraph 0.7.24__tar.gz → 0.7.25__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 (124) hide show
  1. {hypgraph-0.7.24 → hypgraph-0.7.25}/CHANGELOG.md +25 -0
  2. {hypgraph-0.7.24 → hypgraph-0.7.25}/PKG-INFO +1 -1
  3. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/RELEASE-READINESS.md +17 -0
  5. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/checks.py +119 -19
  7. hypgraph-0.7.25/tests/test_counts_that_bear.py +160 -0
  8. {hypgraph-0.7.24 → hypgraph-0.7.25}/.gitignore +0 -0
  9. {hypgraph-0.7.24 → hypgraph-0.7.25}/AGENTS.md +0 -0
  10. {hypgraph-0.7.24 → hypgraph-0.7.25}/CITATION.cff +0 -0
  11. {hypgraph-0.7.24 → hypgraph-0.7.25}/DISCLAIMER.md +0 -0
  12. {hypgraph-0.7.24 → hypgraph-0.7.25}/LICENSE +0 -0
  13. {hypgraph-0.7.24 → hypgraph-0.7.25}/NOTICE +0 -0
  14. {hypgraph-0.7.24 → hypgraph-0.7.25}/README.md +0 -0
  15. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  16. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  17. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/profile-workflows-plan.md +0 -0
  18. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/review-2026-10-24.md +0 -0
  19. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/site-plan.md +0 -0
  20. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/spec.md +0 -0
  21. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/ui-plan.md +0 -0
  22. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/verification.md +0 -0
  23. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/workflows/README.md +0 -0
  24. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/workflows/knowledge.md +0 -0
  25. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/workflows/optimization.md +0 -0
  26. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/workflows/scientific.md +0 -0
  27. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/workflows/software.md +0 -0
  28. {hypgraph-0.7.24 → hypgraph-0.7.25}/docs/workflows/trading.md +0 -0
  29. {hypgraph-0.7.24 → hypgraph-0.7.25}/examples/frameworks/README.md +0 -0
  30. {hypgraph-0.7.24 → hypgraph-0.7.25}/pyproject.toml +0 -0
  31. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/__main__.py +0 -0
  32. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/blind.py +0 -0
  33. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/bundle.py +0 -0
  34. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/cli.py +0 -0
  35. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/coord.py +0 -0
  36. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/demos/__init__.py +0 -0
  37. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/demos/broad_street.py +0 -0
  38. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/demos/linked.py +0 -0
  39. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/derive.py +0 -0
  40. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/graph.py +0 -0
  41. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/language.py +0 -0
  42. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/mcp.py +0 -0
  43. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/plan.py +0 -0
  44. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/policy.py +0 -0
  45. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/profiles.py +0 -0
  46. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/references.py +0 -0
  47. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/refusals.py +0 -0
  48. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/report_check.py +0 -0
  49. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/schema.py +0 -0
  50. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/server.py +0 -0
  51. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/skill/SKILL.md +0 -0
  52. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/stats.py +0 -0
  53. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/store.py +0 -0
  54. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/__init__.py +0 -0
  55. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/atlas.css +0 -0
  56. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/atlas.js +0 -0
  57. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/hypgraph.css +0 -0
  58. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  59. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/hypgraph.js +0 -0
  60. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/hypgraph.svg +0 -0
  61. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/index.html +0 -0
  62. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  63. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  64. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  65. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  66. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  67. {hypgraph-0.7.24 → hypgraph-0.7.25}/src/hypgraph/workflow.py +0 -0
  68. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_accept_work_kind.py +0 -0
  69. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_anchoring.py +0 -0
  70. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_blind.py +0 -0
  71. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_blind_oracles.py +0 -0
  72. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_bound_support.py +0 -0
  73. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_bundle_telemetry.py +0 -0
  74. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_cancel_releases_lease.py +0 -0
  75. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_cited_and_failed_support.py +0 -0
  76. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_contradictions.py +0 -0
  77. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_coord.py +0 -0
  78. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_d10.py +0 -0
  79. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_direction_and_units.py +0 -0
  80. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_engine.py +0 -0
  81. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_engine_fixes.py +0 -0
  82. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_framework_examples.py +0 -0
  83. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_i18n.py +0 -0
  84. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_incremental.py +0 -0
  85. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_link_translate.py +0 -0
  86. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_linked_records.py +0 -0
  87. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_log_and_check.py +0 -0
  88. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_measure_questions.py +0 -0
  89. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_opposed_answers.py +0 -0
  90. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_option_lead.py +0 -0
  91. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_order_detail.py +0 -0
  92. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_profiles.py +0 -0
  93. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_profiles_041.py +0 -0
  94. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_quantifiers_and_identity.py +0 -0
  95. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_questions_not_assertions.py +0 -0
  96. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_references.py +0 -0
  97. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_refusal_friction.py +0 -0
  98. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_refusal_results.py +0 -0
  99. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_refusals.py +0 -0
  100. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_relation_words.py +0 -0
  101. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_report_answers.py +0 -0
  102. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_rerunnable.py +0 -0
  103. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_restates_scale.py +0 -0
  104. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_server_auth.py +0 -0
  105. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_server_hosts.py +0 -0
  106. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_shape_refusals.py +0 -0
  107. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_stats_not_backtests.py +0 -0
  108. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_stats_values.py +0 -0
  109. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_surfaces.py +0 -0
  110. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_telemetry_stream.py +0 -0
  111. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_trust_boundary.py +0 -0
  112. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_ui_app.py +0 -0
  113. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_ui_engine.py +0 -0
  114. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_uncorrected_effect.py +0 -0
  115. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_unnamed_inputs.py +0 -0
  116. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_verify_complete.py +0 -0
  117. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_verify_lifecycle.py +0 -0
  118. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_verify_scope.py +0 -0
  119. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_verify_scoped_views.py +0 -0
  120. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_verify_trust.py +0 -0
  121. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_verify_views.py +0 -0
  122. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_versions_not_figures.py +0 -0
  123. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_vocabulary_friction.py +0 -0
  124. {hypgraph-0.7.24 → hypgraph-0.7.25}/tests/test_workflows.py +0 -0
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.25 — 2026-11-01: which counts bear on a claim, joined comparisons, boundaries in names
4
+
5
+ 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
6
+ (`tests/test_counts_that_bear.py`).
7
+
8
+ - **A count with no comparison doesn't back one** (HG-024-01). With no comparison of its own, a count of none or all
9
+ was read the old way, so "3 of 3 routes processed" backed "nothing is longer than 26 km" and "every route is longer
10
+ than 26 km". A count that states no comparison now doesn't bear on a claim that rules one out
11
+ (`ABSOLUTE_WITHOUT_COUNT`). A claim with no comparison ("no route ever failed") reads a plain count as before.
12
+ - **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
13
+ word, so none of 3 cargo routes longer than 26 km backed "no passenger route is longer than 26 km" while the passenger
14
+ count was 1 of 3, and backed "no route is longer" too. A count names what it counts in the plural ("cargo routes");
15
+ where the statement uses that word, the words right before it say which kind. A count backs a claim only if every
16
+ kind it names is one the statement names; a count of one kind that finds some still refutes a claim about all, and
17
+ two kinds named apart (cargo, passenger) are left unresolved.
18
+ - **Two comparisons joined in one clause** (HG-024-03). "And" ended the clause, so "no route is shorter than 20 km and
19
+ longer than 30 km" was refused by a route shorter than 20, and "every route is longer than 10 km and shorter than 30
20
+ km" went in with "shorter than 30" unread, even with the count that refutes it bound. An "and" or "or" before a
21
+ 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
22
+ 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
23
+ and a count of each part on its own leaves them unresolved.
24
+ - **A count's name must say its boundary** (HG-024-04). Only a name pointing the other way was caught: a `>=` count
25
+ named "routes longer than 26 km", or a `>` count named "26 km or longer", went in. A name that leaves the figure out
26
+ where the count took it in, or the other way round, is now `MEASUREMENT_MISMATCH`.
27
+
3
28
  ## 0.7.24 — 2026-11-01: what a quantifier needs, where a comparison's boundary falls, one check per extreme
4
29
 
5
30
  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.25
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.7.24 (2026-11-01)
112
+ ## Status at 0.7.25 (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,23 @@ 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
+
152
169
  ## Recommendation: CONDITIONAL GO
153
170
 
154
171
  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.25"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -219,6 +219,15 @@ def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]
219
219
  f"way from what result {m['result']} counted for '{m['key']}' ({op})",
220
220
  fix=f"name the count after the comparison it made ({op})"))
221
221
  bad = True
222
+ # "longer than 26" leaves 26 out, "at least 26" and "26 or longer" take it in: a name that says the other
223
+ # boundary from the one counted says a different count (audit HG-024-04)
224
+ if not bad and _OPS.get(op) and any(c and not _opposed(c, ("", _OPS[op][0])) and c[2] != _OPS[op][1]
225
+ for _f, c in _clause_comparisons(str(m.get("name") or ""))):
226
+ problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: its name "
227
+ f"{'leaves out' if _OPS[op][1] else 'takes in'} the figure itself, but result "
228
+ f"{m['result']} counted '{m['key']}' with {op}",
229
+ fix=f"name the count after the comparison it made ({op})"))
230
+ bad = True
222
231
  if not bad:
223
232
  good.append((m, rv))
224
233
  return good, problems
@@ -430,19 +439,50 @@ def _clause_comparisons(clause: str) -> list[tuple[float, tuple[str, int, bool]
430
439
  return out
431
440
 
432
441
 
433
- def _absolute_targets(stmt: str, w: str) -> list[tuple[frozenset, frozenset]]:
442
+ # "and" or "or" before a comparison with no subject of its own: "shorter than 20 km and longer than 30 km"
443
+ _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
+ r"beyond\b|exceed)", re.I)
445
+
446
+
447
+ def _clause_end(stmt: str, start: int) -> int:
448
+ """Where the clause that starts at ``start`` ends. An "and" that joins a second comparison to the first ("no route
449
+ is shorter than 20 km and longer than 30 km") doesn't end it: the two say one thing (audit HG-024-03)."""
450
+ for x in _SUBCLAUSE.finditer(stmt, start):
451
+ if not (x.group(0).lower() == "and" and _JOINED.match(stmt, x.end())):
452
+ return x.start()
453
+ return len(stmt)
454
+
455
+
456
+ def _target_groups(stmt: str, w: str) -> list[list[tuple[frozenset, frozenset]]]:
434
457
  """What an absolute rules out, as (figures, comparisons of the exceptions), one per thing it is about. A comparison
435
458
  in its own clause: "nothing longer than 26 km" rules out longer than 26, and "every route is longer than 26 km"
436
459
  rules out 26 or shorter (audit HG-023-01). Not "every" in a clause that states its own count ("a count of every
437
460
  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]] = []
461
+ extreme the statement claims, on its own (HG-023-03).
462
+
463
+ Each group is ruled out together. Two comparisons joined in one clause are two groups where either exception
464
+ falsifies the claim ("no route is shorter than 20 km or longer than 30 km"; "every route is longer than 10 km and
465
+ shorter than 30 km"), and one group where only an exception to both does ("no route is shorter than 20 km and
466
+ 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)."""
468
+ out: list[list[tuple[frozenset, frozenset]]] = []
440
469
  every = bool(_ALL.match(w)) and not _names_stated_count(stmt, w)
441
470
  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())]
471
+ clause = stmt[m.end():_clause_end(stmt, m.end())]
472
+ targets = [(frozenset({fig}), frozenset() if c is None else frozenset({_negated(c) if every else c}))
473
+ for fig, c in _clause_comparisons(clause)]
474
+ ands = any(_JOINED.match(clause, x.end()) for x in re.finditer(r"\band\b", clause, re.I))
475
+ 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):
477
+ out.append(targets)
478
+ else:
479
+ out += [[x] for x in targets]
480
+ return out or [[x] for x in _extreme_targets(stmt) or [(frozenset(), frozenset())]]
481
+
482
+
483
+ def _absolute_targets(stmt: str, w: str) -> list[tuple[frozenset, frozenset]]:
484
+ """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]
446
486
 
447
487
 
448
488
  def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
@@ -451,19 +491,21 @@ def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
451
491
  return {(axis, sign) for _f, comps in targets for axis, sign, _strict in comps}
452
492
 
453
493
 
454
- def _role(m: dict, rv: dict, a: tuple[int, int], figs: set[float], comps: set) -> str | None:
494
+ def _role(m: dict, rv: dict, a: tuple[int, int], figs: set[float], comps: set, stmt: str = "") -> str | None:
455
495
  """What a bound count does to a claim that rules out ``comps`` at ``figs``: 'back' it (none of the exceptions
456
496
  found), 'refute' it (some found), or nothing. A count of what lies outside the exceptions is read as its
457
497
  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
498
  covers the exceptions, and refutes only if every one it found is one: two routes at least 26 km don't refute "no
459
499
  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."""
500
+ name and key could say different comparisons, a label can widen what a count refutes, never what it backs. A count
501
+ that says no comparison doesn't bear on a claim that rules one out: 3 of 3 routes processed says nothing about
502
+ routes longer than 26 km (audit HG-024-01). A claim whose comparison can't be told is read as before: none or all
503
+ backs, some refutes. With ``stmt``, the count must also be about what the claim is (``_subject_fit``)."""
462
504
  k, n = a
463
505
  cs = _count_comparisons(m, rv)
464
- if cs is None and comps:
506
+ if comps and not cs:
465
507
  return None
466
- if not cs or not comps:
508
+ if not comps:
467
509
  role = "back" if k in (0, n) else "refute"
468
510
  else:
469
511
  roles = set()
@@ -484,9 +526,59 @@ def _role(m: dict, rv: dict, a: tuple[int, int], figs: set[float], comps: set) -
484
526
  c = _count_figures(m, rv, figs)
485
527
  if c and figs and not (c & figs if role == "refute" else c <= figs):
486
528
  return None
529
+ if stmt:
530
+ covers, apart = _subject_fit(stmt, m)
531
+ if (role == "back" and not covers) or (role == "refute" and apart):
532
+ return None
487
533
  return role
488
534
 
489
535
 
536
+ # words a count's name may use for the counting itself, not for what it counts
537
+ _COUNTING = set("count counts counted counting number numbers total totals tally share fraction rate found measured "
538
+ "checked matching".split())
539
+ # and words that say how many, not which kind: "nothing worth more", "every route"
540
+ _HOW_MANY = set("nothing none never nobody every always ever each whole entire".split())
541
+
542
+
543
+ def _modifiers(words: list[str], i: int) -> set[str]:
544
+ """The content words right before ``words[i]``, up to a determiner, number, preposition or comparison: "no passenger
545
+ route" gives {passeng}, "the 3 cargo routes" {cargo}, "the shortest route" nothing."""
546
+ out: set[str] = set()
547
+ for w in reversed(words[:i]):
548
+ cw = L.content_words(w)
549
+ if not cw or w in _DETERMINERS or w in _PREPOSITIONS or w in _DIRECTION_OF or w in _COUNTING | _HOW_MANY:
550
+ break
551
+ out |= cw
552
+ return out
553
+
554
+
555
+ def _subject_fit(stmt: str, m: dict) -> tuple[bool, bool]:
556
+ """Is a count about what a claim is about? Returns (covers, apart). A count names what it counts in the plural
557
+ ("cargo routes", "fitting selections"); where the statement uses that word too, the words right before it on each
558
+ side say which kind: "cargo routes" are cargo routes. The count covers the claim if every kind it names is one the
559
+ statement names too: none of 3 cargo routes longer than 26 km says nothing of passenger routes, nor of every route
560
+ (audit HG-024-02). They are apart if each names a kind the other doesn't (cargo, passenger); a count of all routes
561
+ with one longer may still be about the passenger route. A count that names nothing the statement does is read as
562
+ before."""
563
+ said = re.findall(r"[a-z]+", stmt.lower())
564
+ stems = [L.content_words(w) for w in said]
565
+ about = set().union(*stems) if stems else set()
566
+ text = f"{m.get('name') or ''} . {str(m.get('key') or '').replace('_', ' ')}".lower()
567
+ words = re.findall(r"[a-z]+|\.", text)
568
+ mine = L.content_words(text)
569
+ covers, apart = True, False
570
+ for i, w in enumerate(words):
571
+ head = L.content_words(w)
572
+ plural = w.endswith("s") and not w.endswith("ss")
573
+ if not head or not plural or w in _DIRECTION_OF or w in _COUNTING | _HOW_MANY or not head & about:
574
+ continue
575
+ kinds = _modifiers(words, i) - about
576
+ theirs = set().union(*(_modifiers(said, j) for j, s in enumerate(stems) if s & head)) - mine
577
+ covers = covers and not kinds
578
+ apart = apart or bool(kinds and theirs)
579
+ return covers, apart
580
+
581
+
490
582
  def _pinned(m: dict, rv: dict, figs: set[float]) -> bool:
491
583
  """A count at the claim's own figure: when it refutes the claim, it does whatever else is bound beside it."""
492
584
  return bool(figs) and bool(_count_figures(m, rv, figs) & figs)
@@ -502,11 +594,19 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
502
594
  out: list[Problem] = []
503
595
  seen: set[tuple] = set()
504
596
  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}]
597
+ for group in _target_groups(stmt, w):
598
+ if (tuple(group), _ALL.match(w) is None) in seen:
599
+ continue
600
+ seen.add((tuple(group), _ALL.match(w) is None))
601
+ got = [_absolute(stmt, scope, body, bound, w, words, set(figs), set(comps)) for figs, comps in group]
602
+ if len(group) > 1: # ruled out together: none of one part means none of both
603
+ if any(not g for g in got):
604
+ continue
605
+ got = [g for g in got if not any(p.code == "COUNT_CONTRADICTS" for p in g)][:1] or [[Problem(
606
+ "ABSOLUTE_WITHOUT_COUNT", f"'{w}' rules out what meets both comparisons at once: a count of each "
607
+ "on its own can't show that something meets both. Back it with a count of either one where k is "
608
+ "0, or count both together", fix="count what meets both comparisons together", words=words)]]
609
+ out += [p for g in got for p in g if p.text() not in {q.text() for q in out}]
510
610
  if not any(p.code == "COUNT_CONTRADICTS" for p in out):
511
611
  out += _superlative_contradicted(stmt, bound)
512
612
  return out
@@ -514,7 +614,7 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
514
614
 
515
615
  def _absolute(stmt: str, scope: str, body: dict, bound: list, w: str, words: list, figs: set[float],
516
616
  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))]
617
+ arms = [(m, rv, a, role) for m, rv in bound if (a := _arm(rv)) and (role := _role(m, rv, a, figs, comps, stmt))]
518
618
  about = L.content_words(stmt)
519
619
  # a count at the claim's own figure that finds exceptions refutes it, whatever else is bound beside it
520
620
  pinned = [(m, a) for m, rv, a, role in arms if role == "refute" and _pinned(m, rv, figs)
@@ -673,7 +773,7 @@ def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
673
773
  about = L.content_words(stmt)
674
774
  for w, figs, comps in _extreme_targets(stmt, named=True):
675
775
  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)))
776
+ and (role := _role(m, rv, a, set(figs), set(comps), stmt))
677
777
  and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
678
778
  or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
679
779
  or stated_figure(rv) in figs)
@@ -0,0 +1,160 @@
1
+ """Which counts bear on a claim, and what two joined comparisons rule out (audit of 0.7.24, HG-024-01..04).
2
+
3
+ A count of routes processed says nothing about routes longer than 26 km. A count of cargo routes says nothing about
4
+ passenger routes, nor about every route. "No route is shorter than 20 km and longer than 30 km" rules out a route that
5
+ is both, so a route shorter than 20 doesn't show it false; "every route is longer than 10 km and shorter than 30 km" is
6
+ false if either fails. And a count made with ">=" is not one of routes "longer than" its figure."""
7
+ import pytest
8
+
9
+ from hypgraph import Graph, GraphError, Policy
10
+
11
+ SCOPE = "The three surveyed routes, of 18, 25 and 35 km, against 10, 20, 26 and 30 km marks."
12
+
13
+
14
+ def record(values):
15
+ g = Graph(policy=Policy(reviewers={"critic"}))
16
+ inv = g.author("curator").investigation("Routes", "Which surveyed route is shortest?")["id"]
17
+ lab = g.author("lab")
18
+ run = lab.add("run", inv, kind="lab", state="succeeded", name="Measured the surveyed routes.")
19
+ r = lab.add("result", inv, kind="computed", outcome="positive", summary="Route counts.",
20
+ values=values, links=[{"rel": "produced", "from": run["id"]}])["id"]
21
+ return g, inv, r
22
+
23
+
24
+ def write(g, inv, statement, links, meas=(), scope=SCOPE):
25
+ return g.author("a").add("claim", inv, statement=statement, scope=scope, measurements=list(meas),
26
+ links=[{"rel": "supports", "from": x, "why": "Cited for the claim."} for x in links])
27
+
28
+
29
+ def codes(e) -> list[str]:
30
+ return [p["code"] for p in e.value.problems]
31
+
32
+
33
+ def count(r, key, name, k, n=3):
34
+ return {"name": name, "result": r, "key": key, "k": k, "n": n}
35
+
36
+
37
+ def typed(k, op=">", figure=26):
38
+ return {"k": k, "n": 3, "figure": figure, "op": op}
39
+
40
+
41
+ @pytest.mark.parametrize("statement,k", [("Nothing is longer than 26 km among the 3 routes.", 3),
42
+ ("No route is longer than 26 km among the 3 routes.", 3),
43
+ ("Every route is longer than 26 km among the 3 routes.", 0),
44
+ ("Every route is longer than 26 km among the 3 routes.", 3)])
45
+ def test_a_count_with_no_comparison_does_not_back_one(statement, k):
46
+ """HG-024-01: with no comparison of its own, a count of none or all backed any claim that ruled one out."""
47
+ g, inv, r = record({"processed": {"k": k, "n": 3}})
48
+ with pytest.raises(GraphError) as e:
49
+ write(g, inv, statement, [r], [count(r, "processed", "routes processed", k)])
50
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
51
+
52
+
53
+ def test_a_claim_with_no_comparison_reads_a_plain_count_as_before():
54
+ g, inv, r = record({"failed": {"k": 0, "n": 3}})
55
+ write(g, inv, "No route ever failed to load among the 3 routes.", [r],
56
+ [count(r, "failed", "routes that failed to load", 0)])
57
+
58
+
59
+ @pytest.mark.parametrize("statement", ["No passenger route is longer than 26 km among the 3 routes.",
60
+ "Nothing is longer than 26 km among the 3 passenger routes.",
61
+ "No route is longer than 26 km among the 3 routes."])
62
+ def test_a_count_of_another_kind_does_not_back_the_claim(statement):
63
+ """HG-024-02: none of 3 cargo routes longer backed a claim about passenger routes (1 of 3 of them longer), and about
64
+ every route, because the count and the claim shared the word "route"."""
65
+ g, inv, r = record({"cargo_longer_26": typed(0), "passenger_longer_26": typed(1)})
66
+ with pytest.raises(GraphError) as e:
67
+ write(g, inv, statement, [r], [count(r, "cargo_longer_26", "cargo routes longer than 26 km", 0)])
68
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
69
+
70
+
71
+ @pytest.mark.parametrize("name", ["passenger routes longer than 26 km", "routes longer than 26 km"])
72
+ def test_a_count_of_the_same_kind_or_all_of_them_backs_it(name):
73
+ g, inv, r = record({"longer_26": typed(0)})
74
+ write(g, inv, "No passenger route is longer than 26 km among the 3 routes.", [r],
75
+ [count(r, "longer_26", name, 0)])
76
+
77
+
78
+ def test_a_count_of_one_kind_still_refutes_a_claim_about_all():
79
+ g, inv, r = record({"cargo_longer_26": typed(1)})
80
+ with pytest.raises(GraphError) as e:
81
+ write(g, inv, "No route is longer than 26 km among the 3 routes.", [r],
82
+ [count(r, "cargo_longer_26", "cargo routes longer than 26 km", 1)])
83
+ assert codes(e) == ["COUNT_CONTRADICTS"]
84
+
85
+
86
+ def test_two_kinds_named_apart_do_not_contradict():
87
+ g, inv, r = record({"cargo_longer_26": typed(1)})
88
+ with pytest.raises(GraphError) as e:
89
+ write(g, inv, "No passenger route is longer than 26 km among the 3 routes.", [r],
90
+ [count(r, "cargo_longer_26", "cargo routes longer than 26 km", 1)])
91
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
92
+
93
+
94
+ BOTH = {"shorter_20": typed(1, "<", 20), "longer_30": typed(1, ">", 30), "shorter_10": typed(0, "<", 10),
95
+ "longer_10": typed(3, ">", 10), "shorter_30": typed(2, "<", 30)}
96
+
97
+
98
+ def test_no_route_both_shorter_and_longer_is_not_refuted_by_either():
99
+ """HG-024-03: "and" ended the clause, so a route shorter than 20 refuted a claim that rules out only a route both
100
+ shorter than 20 and longer than 30. With no count of both, it's unresolved."""
101
+ g, inv, r = record(BOTH)
102
+ with pytest.raises(GraphError) as e:
103
+ write(g, inv, "No route is shorter than 20 km and longer than 30 km among the 3 routes.", [r],
104
+ [count(r, "shorter_20", "routes shorter than 20 km", 1),
105
+ count(r, "longer_30", "routes longer than 30 km", 1)])
106
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
107
+
108
+
109
+ def test_none_of_one_part_backs_none_of_both():
110
+ g, inv, r = record(BOTH)
111
+ write(g, inv, "No route is shorter than 10 km and longer than 30 km among the 3 routes.", [r],
112
+ [count(r, "shorter_10", "routes shorter than 10 km", 0)])
113
+
114
+
115
+ def test_no_route_shorter_or_longer_is_refuted_by_either():
116
+ g, inv, r = record(BOTH)
117
+ with pytest.raises(GraphError) as e:
118
+ write(g, inv, "No route is shorter than 20 km or longer than 30 km among the 3 routes.", [r],
119
+ [count(r, "shorter_20", "routes shorter than 20 km", 1),
120
+ count(r, "longer_30", "routes longer than 30 km", 1)])
121
+ assert "COUNT_CONTRADICTS" in codes(e)
122
+
123
+
124
+ def test_every_route_between_two_marks_needs_both():
125
+ """The same split left "shorter than 30 km" unread after "every route is longer than 10 km and": the claim went in
126
+ with 2 of 3 shorter than 30 bound beside it."""
127
+ g, inv, r = record(BOTH)
128
+ statement = "Every route is longer than 10 km and shorter than 30 km among the 3 routes."
129
+ with pytest.raises(GraphError) as e:
130
+ write(g, inv, statement, [r], [count(r, "longer_10", "routes longer than 10 km", 3)])
131
+ assert codes(e) == ["ABSOLUTE_WITHOUT_COUNT"]
132
+ with pytest.raises(GraphError) as e:
133
+ write(g, inv, statement, [r], [count(r, "longer_10", "routes longer than 10 km", 3),
134
+ count(r, "shorter_30", "routes shorter than 30 km", 2)])
135
+ assert codes(e) == ["COUNT_CONTRADICTS"]
136
+
137
+
138
+ def test_and_with_a_subject_of_its_own_still_ends_the_clause():
139
+ g, inv, r = record(BOTH)
140
+ with pytest.raises(GraphError) as e:
141
+ write(g, inv, "No route is shorter than 20 km and the register lists 3 routes.", [r],
142
+ [count(r, "shorter_20", "routes shorter than 20 km", 1)])
143
+ assert codes(e) == ["COUNT_CONTRADICTS"]
144
+
145
+
146
+ @pytest.mark.parametrize("op,name", [(">=", "routes longer than 26 km"), (">", "routes 26 km or longer"),
147
+ (">", "routes at least 26 km"), ("<", "routes no longer than 26 km")])
148
+ def test_a_name_must_say_the_boundary_the_count_used(op, name):
149
+ """HG-024-04: only a name pointing the other way was caught; "longer than 26" on a ">=" count went in."""
150
+ g, inv, r = record({"routes_26": typed(2, op)})
151
+ with pytest.raises(GraphError) as e:
152
+ write(g, inv, "2 of 3 routes are at least 26 km long.", [r], [count(r, "routes_26", name, 2)])
153
+ assert "MEASUREMENT_MISMATCH" in codes(e)
154
+
155
+
156
+ @pytest.mark.parametrize("op,name", [(">=", "routes at least 26 km"), (">=", "routes 26 km or longer"),
157
+ (">", "routes longer than 26 km"), ("<=", "routes no longer than 26 km")])
158
+ def test_a_name_that_says_the_boundary_is_accepted(op, name):
159
+ g, inv, r = record({"routes_26": typed(2, op)})
160
+ write(g, inv, "2 of 3 routes are at least 26 km long.", [r], [count(r, "routes_26", name, 2)])
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