hypgraph 0.7.18__tar.gz → 0.7.20__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 (121) hide show
  1. {hypgraph-0.7.18 → hypgraph-0.7.20}/CHANGELOG.md +40 -0
  2. {hypgraph-0.7.18 → hypgraph-0.7.20}/PKG-INFO +1 -1
  3. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/RELEASE-READINESS.md +18 -0
  5. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/verification.md +7 -0
  6. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/__init__.py +1 -1
  7. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/checks.py +312 -1
  8. hypgraph-0.7.20/src/hypgraph/demos/linked.py +167 -0
  9. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/graph.py +8 -2
  10. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/language.py +13 -2
  11. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/refusals.py +282 -6
  12. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/hypgraph.css +14 -1
  13. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/hypgraph.i18n.js +4 -0
  14. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/hypgraph.js +427 -26
  15. hypgraph-0.7.20/tests/test_contradictions.py +326 -0
  16. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_engine.py +1 -1
  17. hypgraph-0.7.20/tests/test_linked_records.py +198 -0
  18. {hypgraph-0.7.18 → hypgraph-0.7.20}/.gitignore +0 -0
  19. {hypgraph-0.7.18 → hypgraph-0.7.20}/AGENTS.md +0 -0
  20. {hypgraph-0.7.18 → hypgraph-0.7.20}/CITATION.cff +0 -0
  21. {hypgraph-0.7.18 → hypgraph-0.7.20}/DISCLAIMER.md +0 -0
  22. {hypgraph-0.7.18 → hypgraph-0.7.20}/LICENSE +0 -0
  23. {hypgraph-0.7.18 → hypgraph-0.7.20}/NOTICE +0 -0
  24. {hypgraph-0.7.18 → hypgraph-0.7.20}/README.md +0 -0
  25. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  26. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  27. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/profile-workflows-plan.md +0 -0
  28. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/review-2026-10-24.md +0 -0
  29. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/site-plan.md +0 -0
  30. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/spec.md +0 -0
  31. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/ui-plan.md +0 -0
  32. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/workflows/README.md +0 -0
  33. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/workflows/knowledge.md +0 -0
  34. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/workflows/optimization.md +0 -0
  35. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/workflows/scientific.md +0 -0
  36. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/workflows/software.md +0 -0
  37. {hypgraph-0.7.18 → hypgraph-0.7.20}/docs/workflows/trading.md +0 -0
  38. {hypgraph-0.7.18 → hypgraph-0.7.20}/examples/frameworks/README.md +0 -0
  39. {hypgraph-0.7.18 → hypgraph-0.7.20}/pyproject.toml +0 -0
  40. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/__main__.py +0 -0
  41. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/blind.py +0 -0
  42. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/bundle.py +0 -0
  43. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/cli.py +0 -0
  44. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/coord.py +0 -0
  45. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/demos/__init__.py +0 -0
  46. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/demos/broad_street.py +0 -0
  47. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/derive.py +0 -0
  48. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/mcp.py +0 -0
  49. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/plan.py +0 -0
  50. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/policy.py +0 -0
  51. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/profiles.py +0 -0
  52. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/references.py +0 -0
  53. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/report_check.py +0 -0
  54. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/schema.py +0 -0
  55. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/server.py +0 -0
  56. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/skill/SKILL.md +0 -0
  57. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/stats.py +0 -0
  58. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/store.py +0 -0
  59. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/__init__.py +0 -0
  60. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/atlas.css +0 -0
  61. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/atlas.js +0 -0
  62. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/hypgraph.svg +0 -0
  63. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/index.html +0 -0
  64. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  65. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  66. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  67. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  68. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  69. {hypgraph-0.7.18 → hypgraph-0.7.20}/src/hypgraph/workflow.py +0 -0
  70. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_accept_work_kind.py +0 -0
  71. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_anchoring.py +0 -0
  72. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_blind.py +0 -0
  73. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_blind_oracles.py +0 -0
  74. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_bound_support.py +0 -0
  75. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_bundle_telemetry.py +0 -0
  76. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_cancel_releases_lease.py +0 -0
  77. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_cited_and_failed_support.py +0 -0
  78. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_coord.py +0 -0
  79. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_d10.py +0 -0
  80. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_engine_fixes.py +0 -0
  81. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_framework_examples.py +0 -0
  82. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_i18n.py +0 -0
  83. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_incremental.py +0 -0
  84. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_link_translate.py +0 -0
  85. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_log_and_check.py +0 -0
  86. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_measure_questions.py +0 -0
  87. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_opposed_answers.py +0 -0
  88. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_option_lead.py +0 -0
  89. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_order_detail.py +0 -0
  90. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_profiles.py +0 -0
  91. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_profiles_041.py +0 -0
  92. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_questions_not_assertions.py +0 -0
  93. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_references.py +0 -0
  94. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_refusal_friction.py +0 -0
  95. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_refusal_results.py +0 -0
  96. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_refusals.py +0 -0
  97. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_relation_words.py +0 -0
  98. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_report_answers.py +0 -0
  99. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_rerunnable.py +0 -0
  100. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_restates_scale.py +0 -0
  101. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_server_auth.py +0 -0
  102. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_server_hosts.py +0 -0
  103. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_shape_refusals.py +0 -0
  104. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_stats_not_backtests.py +0 -0
  105. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_stats_values.py +0 -0
  106. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_surfaces.py +0 -0
  107. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_telemetry_stream.py +0 -0
  108. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_trust_boundary.py +0 -0
  109. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_ui_app.py +0 -0
  110. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_ui_engine.py +0 -0
  111. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_uncorrected_effect.py +0 -0
  112. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_unnamed_inputs.py +0 -0
  113. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_verify_complete.py +0 -0
  114. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_verify_lifecycle.py +0 -0
  115. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_verify_scope.py +0 -0
  116. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_verify_scoped_views.py +0 -0
  117. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_verify_trust.py +0 -0
  118. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_verify_views.py +0 -0
  119. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_versions_not_figures.py +0 -0
  120. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_vocabulary_friction.py +0 -0
  121. {hypgraph-0.7.18 → hypgraph-0.7.20}/tests/test_workflows.py +0 -0
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.20 — 2026-11-01: a claim the record can show false, and who keeps making it
4
+
5
+ - **A claim the record's own counts show false is refused as false** (`COUNT_CONTRADICTS`). "The shortest route is
6
+ 31 km" beside a bound count of 2 of 81 routes shorter, or "nothing worth more fits" beside 1 of 1,024 that does, used
7
+ to fail at most as a missing denominator; now the count is named as the contradiction. An extreme claimed of a figure
8
+ no answer reaches is refused too, when the instrument also counts what reaches the figure exactly (0 of n). Not
9
+ claims: a denied extreme ("120 is not the highest"), the domain a stated count ran over ("a count of every route
10
+ finds 1 of 74 shorter") and a stated count itself ("is 1 of 63 routes").
11
+ - **A source cited as support that says the opposite is refused** (`SOURCE_CONTRADICTS`): "the weights are in
12
+ pounds" citing the note that every weight is in kilograms, or casting doubt on what that note asserts without
13
+ denying it ("not independently established", "the only note calling them kilograms"). It no longer counts as a
14
+ line of support for the claim that contradicts it.
15
+ - **Conduct: who corrected and who kept saying it.** `Graph.conduct(inv, author)` reads the refusal log per author:
16
+ claims shown false, corrections, the same claim said again (`reasserted`; a correction that names the old figure to
17
+ deny it is not a repeat), and where each author ends (`still_saying`, `moved_off`, `ends_false`). Refusal rows carry
18
+ the event `seq`, so a claim accepted later that moves off a refused one is seen.
19
+ - `content_words` stems a word and its inflections alike (route / routes, fits / fitting), so a count's name meets
20
+ the sentence it bears on. `language.SUPERLATIVE` is exported. Tests: `tests/test_contradictions.py`.
21
+
22
+ ## 0.7.19 — 2026-10-31: linked records, drawn together
23
+
24
+ - **The Explorer draws linked records together.** When an investigation pins nodes of other records (pinned
25
+ references, 0.7.8), its graph now draws each of those records beside it, each in its own box, and follows their
26
+ pins in turn: a parent that pins child 1 and child 2 shows all three, with every pin drawn from the citing source to
27
+ the node it pins. Each record is read as its own bundle with its hash chain re-verified, and each pin is checked
28
+ against the version it names (`checkPin`, held to `verify_reference`'s verdicts by `tests/test_linked_records.py`):
29
+ green when the hash matches, amber when the node was revised since, red when it was withdrawn or doesn't match.
30
+ Beside each pin is the pinned node's standing in its own record. Nothing is merged; the records are laid out in
31
+ columns by distance, each mirrored to keep its pins short. A *Linked records* panel under the graph lists each
32
+ record, its chain, and every pin into it; the overview has a card, and a node of a linked record opens in the
33
+ drawer from its own bundle. Following stops at three links and twelve records by default (`linkDepth`,
34
+ `linkMax`); another site is read only when the viewer asks (`linked: 'same-origin' | 'all' | 'off'`), and a page
35
+ can map record URLs to bundles (`records`, `recordUrl`). `HypGraph.linkRecords`, `checkPin`, `bundleReferences`,
36
+ `normRecord` and `resolveWith` are exported. Translated into es, fr, de and zh-Hans.
37
+ - **A worked example on the site.** `hypgraph.demos.linked` builds three records joined by pins: Broad Street, 1854
38
+ (the demo, unchanged), East London, 1866 (Farr's inquiry, which pins Broad Street's comparison as what it repeats)
39
+ and a synthesis, London's water, 1854–1866, which pins claims in both. `examples/linked/` serves each record as
40
+ its own page and bundle, opening on the graph.
41
+ - The site's `.sub` style no longer leaks into the app's card headings.
42
+
3
43
  ## 0.7.18 — 2026-10-31: a scoped bundle can't call an active node superseded
4
44
 
5
45
  - **A scoped bundle can't show an active node as superseded and still say its views are checked.** A re-audit of
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.18
3
+ Version: 0.7.20
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.18 (2026-10-31)
112
+ ## Status at 0.7.20 (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
@@ -70,6 +70,24 @@ was taken from a changelog without being checked against the code.*
70
70
  > **Where that stands.** #77 is merged with green CI (17 of 17 checks); from the 0.7.18 wheel in a fresh venv the
71
71
  > suite is 431 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Released as 0.7.18.
72
72
 
73
+ > **Linked records (2026-10-31), for 0.7.19.** The Explorer now draws the records an investigation pins beside it,
74
+ > and the records those pin in turn (three links, twelve records at most by default), each in its own box, with every
75
+ > pin drawn to the node it names. Nothing is merged: each record is read as its own bundle with its chain re-verified,
76
+ > and each pin is checked against the version it names by hashing the exact stored body; `checkPin` is held to
77
+ > `verify_reference`'s verdicts. Another site is read only when the viewer asks. The site has a worked example of three
78
+ > linked records in five languages. Tests: `tests/test_linked_records.py`; `dev/ui_smoke.py` 149 of 149 pages.
79
+ >
80
+ > **Where that stands.** #79 is merged with green CI (17 of 17 checks); from the 0.7.19 wheel in a fresh venv the
81
+ > suite is 436 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Released as 0.7.19.
82
+
83
+ > **Claims the record can show false (2026-11-01), for 0.7.20.** A claim a bound count contradicts is refused as
84
+ > `COUNT_CONTRADICTS` (including an extreme no answer reaches), a claim citing a source that asserts the opposite or
85
+ > doubts it as `SOURCE_CONTRADICTS`, and `Graph.conduct()` tells an author who corrected from one who said it again.
86
+ > Tests: `tests/test_contradictions.py`.
87
+ >
88
+ > **Where that stands.** #81 is merged with green CI (17 of 17 checks); from the 0.7.20 wheel in a fresh venv the
89
+ > suite is 450 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Released as 0.7.20.
90
+
73
91
  ## Recommendation: CONDITIONAL GO
74
92
 
75
93
  HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
@@ -107,6 +107,13 @@ separate implementation.
107
107
  investigation, fails; so does a bundle without a scope that has redacted events.
108
108
  - A source that pins another record's node checks that the cited body still hashes to the pinned version. It doesn't
109
109
  check the cited record's history: for that, verify that record against its own published head.
110
+ - The Explorer's graph draws the records an investigation pins beside it, and the records those pin in turn (three
111
+ links deep, twelve records at most by default). Each is read as its own bundle and its chain re-verified, and each
112
+ pin is checked against the version it names, hashing the exact stored body as `verify_reference` does. Nothing is
113
+ merged: each record keeps its own standing, shown beside the pin ("checked there", "unchecked there"). A bundle
114
+ can't prove the address it was served from, so a pin ties the citation to the version's hash, not to the URL. The
115
+ page reads another site only when the viewer asks; same-origin records, and records the page maps to a bundle
116
+ (`records` in `HypGraph.mount`), are read at once.
110
117
 
111
118
  ## Try it
112
119
 
@@ -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.18"
19
+ __version__ = "0.7.20"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -16,6 +16,10 @@ and each refusal does the arithmetic the writer skipped, then offers the honest
16
16
  ABSOLUTE_WITHOUT_COUNT "never" / "always" / "eliminates" / "100%" with no count of 0 or n behind it
17
17
  (an absolute over a set the sentence enumerates — "all three subsets: …" — is
18
18
  not a generalisation and is left alone)
19
+ COUNT_CONTRADICTS the absolute is contradicted by a bound count of what it's about: k of n with
20
+ 0 < k < n, the exceptions it says don't exist were counted. Not a missing count
21
+ (ABSOLUTE_WITHOUT_COUNT) but a false statement, and the refusal log tracks whether
22
+ its author then corrects it or says it again (refusals.conduct)
19
23
  ABSOLUTE_UNRELATED_COUNT the 0-of-n it leans on counts something else
20
24
  ABSOLUTE_UNDERPOWERED 0 of n with n too small (the rate could still be above 5%), and the statement
21
25
  generalises past the n trials instead of saying them ("never leaks")
@@ -25,6 +29,11 @@ and each refusal does the arithmetic the writer skipped, then offers the honest
25
29
  SIGNIFICANCE_NOT_SHOWN the test behind it isn't significant
26
30
  SIGNIFICANCE_CONTRADICTED "not significant", but the bound evidence is (p < 0.05)
27
31
  CAUSAL_WITHOUT_INTERVENTION "causes" / "leads to" when no experiment intervened
32
+ SOURCE_CONTRADICTS the statement denies a word ("…in pounds, not kilograms") that a source it links
33
+ as support asserts, in a sentence with no negation of its own ("every weight is
34
+ in kilograms"): the source says the opposite, so it can't be a line of support.
35
+ (Before this, a claim citing its own refutation was accepted with "3 independent
36
+ lines of support".)
28
37
  EXCLUSION_WITHOUT_POWER "no effect" / "rules out" without a search shown to find a planted effect
29
38
  CONVENTION_HEDGED a convention that hedges
30
39
  VALUE_NOT_A_NUMBER (results) a value's k or n is not a number: {"k": "40/50", "n": "rank 1 of 20"}
@@ -248,12 +257,19 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
248
257
  words = [w for w in L.asserted(L.ABSOLUTE, stmt) if not _enumerated(stmt, w)]
249
258
  if any(str(m.get("key") or "").lower() in ("gap", "bound") for m, _rv in bound):
250
259
  words = [w for w in words if not _PROVEN.match(w)] # an optimum with its bound (gap 0) is proven
260
+ words = [w for w in words if not _names_counted_set(stmt, w, bound)]
251
261
  if not words:
252
- return []
262
+ return _superlative_contradicted(stmt, bound)
253
263
  w = words[0]
254
264
  arms = [(m, rv, _arm(rv)) for m, rv in bound if _arm(rv)]
255
265
  exact = [(m, rv, a) for m, rv, a in arms if a[0] in (0, a[1])]
256
266
  if not exact:
267
+ about = L.content_words(stmt)
268
+ against = [(m, a) for m, _rv, a in arms if 0 < a[0] < a[1]
269
+ and L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about]
270
+ if against:
271
+ m, (k, n) = max(against, key=lambda x: x[1][1])
272
+ return [_count_contradicts(stmt, w, words, m, k, n)]
257
273
  seen = ", ".join(f"{a[0]} of {a[1]:,}" for _m, _rv, a in arms)
258
274
  return [Problem("ABSOLUTE_WITHOUT_COUNT",
259
275
  f"'{w}' is absolute: back it with a k/n measurement of what it's about, where k is 0 or n, and "
@@ -295,6 +311,298 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
295
311
  fix=f"add 'in {n:,} trials'", words=words)]
296
312
 
297
313
 
314
+ _STATED_COUNT = re.compile(r"\b(\d[\d,]*) of (\d[\d,]*)\b")
315
+ _CLAUSE = re.compile(r"[;!?]|\.(?!\d)")
316
+
317
+
318
+ def _names_counted_set(stmt: str, w: str, bound: list) -> bool:
319
+ """Is the absolute the set a count the statement itself reports ran over: "a count of every possible selection
320
+ finds 2 of 1,024 that are worth more"? It names what was counted, not a claim that nothing lies outside it. (Read
321
+ as an absolute, the record refused that honest correction as the lie said again.)"""
322
+ return _names_stated_count(stmt, w, {a for _m, rv in bound if (a := _arm(rv))})
323
+
324
+
325
+ def _names_stated_count(stmt: str, w: str, counts: set | None = None) -> bool:
326
+ """Does ``w``'s clause state a k-of-n count (one of ``counts``, when given)?"""
327
+ for c in _CLAUSE.split(stmt):
328
+ if re.search(r"\b" + re.escape(w) + r"\b", c, re.I) and any(
329
+ counts is None or (int(a.replace(",", "")), int(b.replace(",", ""))) in counts
330
+ for a, b in _STATED_COUNT.findall(c)):
331
+ return True
332
+ return False
333
+
334
+
335
+ def claimed_extremes(stmt: str) -> list[str]:
336
+ """What a sentence claims as an extreme: its asserted superlatives (``_extremes``) and absolutes, less an absolute
337
+ that names the set a count in its own clause ran over ("a count of every route finds 1 of 74 shorter"). The refusal
338
+ log reads a later write with this to tell a correction from the lie said again."""
339
+ return _extremes(stmt) + [w for w in L.asserted(L.ABSOLUTE, stmt)
340
+ if not _enumerated(stmt, w) and not _names_stated_count(stmt, w)]
341
+
342
+
343
+ # the extreme's own predicate denied after it, in its clause: "the highest total value ... is not 130"
344
+ _DENIED_EXTREME = re.compile(r"\b(?:is|are|was|were)\s+(?:not|no longer|never)\b|\b(?:isn't|aren't|wasn't|weren't)\b|"
345
+ # or said to lie beyond a figure: "the true maximum is higher than 98"
346
+ r"\b(?:is|are|was|were|lies)\s+(?:\w+\s+)?(?:higher|lower|greater|larger|smaller|more|less|"
347
+ r"above|below|over|under|shorter|longer|beyond)\b", re.I)
348
+ # or denied before it: "120 is not the highest total value", "isn't the shortest"
349
+ _DENIED_BEFORE = re.compile(r"(?:\b(?:is|are|was|were)\s+(?:not|no longer|never)|\b(?:isn't|aren't|wasn't|weren't))"
350
+ r"\s+(?:the\s+|a\s+|an\s+)?(?:true\s+|real\s+|actual\s+)?$", re.I)
351
+ _COPULA = re.compile(r"\b(?:is|are|was|were|isn't|aren't|wasn't|weren't|lies)\b", re.I)
352
+
353
+
354
+ def _denied(after: str) -> bool:
355
+ """Does the extreme's own predicate — the first copula after it — deny it or put it beyond a figure? Only that
356
+ one: "the highest value is 98 ..., so the true maximum is higher" still claims 98."""
357
+ cop = _COPULA.search(after)
358
+ return bool(cop and _DENIED_EXTREME.match(after, cop.start()))
359
+
360
+
361
+ # a count named for what lies beyond a figure ("fitting selections worth more", "shorter routes connecting") is about
362
+ # any extreme the statement claims, whatever words the statement uses for it ("the highest total value is 99")
363
+ _BEYOND = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|slower|"
364
+ r"larger|smaller|above|below|exceed\w*|beat\w*)\b", re.I)
365
+
366
+
367
+ def _extremes(stmt: str) -> list[str]:
368
+ """The superlatives a statement asserts of a figure. Not one whose clause goes on to deny it: "the highest total
369
+ value that fits is not 130" corrects the extreme, it doesn't claim one. Nor one whose clause names nothing after
370
+ it ("the shortest route must be computed from the figures as kilometres"): no count can contradict an extreme of
371
+ nothing in particular. (The record used to refuse both as false and log corrections as the lie said again.)"""
372
+ out = []
373
+ for w in L.asserted(L.SUPERLATIVE, stmt):
374
+ for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
375
+ end = next((x.start() for x in _CLAUSE.finditer(stmt, m.end())), len(stmt))
376
+ start = max((x.end() for x in _CLAUSE.finditer(stmt, 0, m.start())), default=0)
377
+ if (not _denied(stmt[m.end():end]) and not _DENIED_BEFORE.search(stmt[start:m.start()])
378
+ and re.search(r"\d", stmt[start:end])):
379
+ out.append(w)
380
+ break
381
+ return out
382
+
383
+
384
+ def _count_contradicts(stmt: str, w: str, words: list, m: dict, k: int, n: int) -> Problem:
385
+ """The refusal for a statement the record's own count shows false. It names what is false in the statement's own
386
+ terms: a count it states that the record's differs from, or the extreme it claims ("highest"), before the word
387
+ that triggered the check."""
388
+ said = [int(a.replace(",", "")) for a, b in _STATED_COUNT.findall(stmt)
389
+ if int(b.replace(",", "")) == n and int(a.replace(",", "")) != k]
390
+ sup = _extremes(stmt)
391
+ head = (f"the statement says {said[0]:,} of {n:,}, but the record's own count is {k:,} of {n:,}" if said
392
+ else f"'{sup[0] if sup else w}' is contradicted by the record's own count: {k:,} of {n:,}")
393
+ return Problem("COUNT_CONTRADICTS",
394
+ f"{head} ({m.get('name') or m.get('key')}). The exceptions the statement rules out were "
395
+ f"counted, so it is false as written: correct it, or say '{k:,} of {n:,}'",
396
+ fix=f"correct the statement, or say '{k:,} of {n:,}'",
397
+ words=words + [f"={_fmt(v)}" for v in _subject_numbers(stmt, k, n)])
398
+
399
+
400
+ def _qualified(stmt: str, k: int, n: int) -> bool:
401
+ """Does the statement give this count about a figure of its own ("1 of 61 routes are shorter than 26 km")?
402
+ Then it's a count of something beyond that figure, not of exceptions to the extreme it claims."""
403
+ forms = {f"{k} of {n}", f"{k:,} of {n:,}"}
404
+ return any(any(f in c for f in forms) and re.search(r"\bthan\b", c) for c in _CLAUSE.split(stmt))
405
+
406
+
407
+ def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
408
+ """An extreme ("the shortest route is 34 km") with no absolute word, where the record's own count of what lies
409
+ beyond it is neither 0 nor all and no exact count backs any figure: refused as false (COUNT_CONTRADICTS)."""
410
+ sup = _extremes(stmt)
411
+ if not sup:
412
+ return []
413
+ about = L.content_words(stmt)
414
+ rel = [(m, a) for m, rv in bound if (a := _arm(rv))
415
+ and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
416
+ or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " ")))]
417
+ if not rel or any(a[0] in (0, a[1]) for _m, a in rel):
418
+ return []
419
+ against = [(m, a) for m, a in rel if 0 < a[0] < a[1] and not _qualified(stmt, *a)]
420
+ if not against:
421
+ return []
422
+ m, (k, n) = max(against, key=lambda x: x[1][1])
423
+ return [_count_contradicts(stmt, sup[0], sup, m, k, n)]
424
+
425
+
426
+ # a count of what reaches a figure exactly: "fitting selections worth exactly 108", "routes exactly 33 km long"
427
+ _REACHES = re.compile(r"\bexactly\s+(-?\d[\d,]*(?:\.\d+)?)", re.I)
428
+ _FIGURE = re.compile(r"\s*(?:[A-Za-z$]+\s+){0,2}?\$?(-?\d[\d,]*(?:\.\d+)?)(?![\d.,]?\d)(?!\s*of\s+\d)")
429
+
430
+
431
+ def _extreme_figures(stmt: str) -> set[float]:
432
+ """The figures a statement's asserted extremes name: the number its own predicate gives ("the highest total value
433
+ that fits within 36 kg is 97" names 97, not 36), else every number in the extreme's clause. A figure elsewhere in
434
+ the sentence ("..., not 97") is not one, nor a stated count ("the shortest route is 1 of 63 routes shorter")."""
435
+ out = set()
436
+ for w in _extremes(stmt):
437
+ for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
438
+ end = next((x.start() for x in _CLAUSE.finditer(stmt, m.end())), len(stmt))
439
+ start = max((x.end() for x in _CLAUSE.finditer(stmt, 0, m.start())), default=0)
440
+ after = stmt[m.end():end]
441
+ if _denied(after) or _DENIED_BEFORE.search(stmt[start:m.start()]):
442
+ continue
443
+ cop = _COPULA.search(after)
444
+ fig = _FIGURE.match(after, cop.end()) if cop else None
445
+ if fig:
446
+ out.add(float(fig.group(1).replace(",", "")))
447
+ elif not (cop and re.match(r"\s*(?:\w+\s+){0,2}?-?\d[\d,]*\s+of\s+\d", after[cop.end():])):
448
+ out |= {v for v, *_ in numbers(stmt[start:end])}
449
+ return out
450
+
451
+
452
+ def _unreached(stmt: str, bound: list) -> list[Problem]:
453
+ """An extreme claimed of a figure nothing reaches. The count of what lies beyond the figure can't show it false
454
+ (nothing beats a figure above the true best), so the instrument also counts what reaches it exactly, naming the
455
+ figure ("fitting selections worth exactly 140"); 0 of n there means no answer is that figure, so it is no extreme.
456
+ (Without it, "the highest value is 140", with an item counted twice against a true best of 113, was accepted.)"""
457
+ sup = _extremes(stmt)
458
+ figs = _extreme_figures(stmt) if sup else set()
459
+ for m, rv in bound:
460
+ a = _arm(rv)
461
+ hit = _REACHES.search(f"{m.get('name', '')}")
462
+ if not a or a[0] != 0 or not hit:
463
+ continue
464
+ x = float(hit.group(1).replace(",", ""))
465
+ if x not in figs:
466
+ continue
467
+ name = m.get("name") or m.get("key")
468
+ return [Problem("COUNT_CONTRADICTS",
469
+ f"'{sup[0]}' names {_fmt(x)}, but the record's own count finds 0 of {a[1]:,} {name}: no "
470
+ f"answer reaches {_fmt(x)}, so it can't be the {sup[0]}. Correct the figure, or say what was "
471
+ f"reached",
472
+ fix="correct the figure to one that is reached", words=sup + [f"={_fmt(x)}"])]
473
+ return []
474
+
475
+
476
+ def _fmt(v: float) -> str:
477
+ return str(int(v)) if float(v).is_integer() else str(v)
478
+
479
+
480
+ def _subject_numbers(stmt: str, k: int, n: int) -> list[float]:
481
+ """The figures a contradicted statement asserts, other than the count itself: what it would have to change to be
482
+ corrected (refusals.conduct reads them to tell a correction from the same claim said again)."""
483
+ return [v for v, *_ in numbers(stmt) if v not in (0, k, n)]
484
+
485
+
486
+ # a word a statement denies: "not kilograms", "doesn't apply", "never reached" (the one or two words after it)
487
+ _DENIAL = re.compile(r"(?:\bnot\b|\bnever\b|n't\b|\brather than\b|\binstead of\b)\s+((?:[A-Za-z]+\s+)?[A-Za-z]+)",
488
+ re.I)
489
+ _PASSAGE_NEGATION = re.compile(r"\b(not|no|never|none|nor|neither|without)\b|n't\b", re.I)
490
+
491
+
492
+ def denied_words(stmt: str) -> set[str]:
493
+ """Content words (stemmed, as language.content_words) that the statement explicitly denies."""
494
+ out: set[str] = set()
495
+ for m in _DENIAL.finditer(stmt or ""):
496
+ out |= L.content_words(m.group(1))
497
+ return out
498
+
499
+
500
+ # casting doubt on a word rather than denying it: "is not independently established that they are kilograms", "the
501
+ # only note calling them kilometres", "conflicts with any pound reading" (a party can cite a note as support and doubt
502
+ # it in these words, which SOURCE_CONTRADICTS' denial check doesn't see)
503
+ _DOUBT = re.compile(
504
+ r"\bnot\s+(?:\w+ly\s+)?(?:established|confirmed|verified|proven|shown|settled|resolved|reliabl\w*|determined)\b"
505
+ r"|\b(?:un(?:confirmed|verified|established|proven|resolved|reliable|corroborated)|disputed|questionable|doubtful)\b"
506
+ r"|\b(?:cannot|can't|can not)\s+be\s+(?:relied|trusted|applied|used|taken|confirmed|verified|established|"
507
+ r"certified|determined)\b"
508
+ r"|\b(?:does|do|did)\s+not\s+(?:establish|confirm|determine|prove|settle|support)\b"
509
+ r"|\b(?:doesn't|don't|didn't)\s+(?:establish|confirm|determine|prove|settle|support)\b"
510
+ r"|\b(?:may|might|could)\s+(?:be|have been)\s+(?:recorded|given|stated|measured|in)\b|\bno\s+independent\b"
511
+ r"|\bonly\s+(?:\w+\s+)?(?:thing|evidence|source|note|basis|assertion|claim|label)\b|\bat issue\b"
512
+ r"|\bin (?:doubt|dispute|question)\b|\bconflicts?\s+with\b|\bcontradicted\s+by\b|\binconsistent\b", re.I)
513
+ _DOUBT_CLAUSE = re.compile(r"([,;:()]|\b(?:because|while|but|although|though|whereas|since|so|and)\b)", re.I)
514
+ # a doubt after these turns on the clause before it: "E12 says kilometres, while the figures may be in miles"
515
+ _TURNS_BACK = re.compile(r"\s*(?:while|but|although|though|whereas)\s*$", re.I)
516
+
517
+
518
+ def doubted_words(stmt: str) -> set[str]:
519
+ """Content words (stemmed) that the statement casts doubt on without denying them: those of a clause with a doubt
520
+ in it, and of the clause before when the doubt turns back on it ("E12 says kilometres, while the figures may be
521
+ in miles"; "the note says kilograms, which conflicts with any pound reading")."""
522
+ parts = _DOUBT_CLAUSE.split(stmt or "")
523
+ clauses, seps = parts[0::2], [""] + parts[1::2]
524
+ out: set[str] = set()
525
+ for i, c in enumerate(clauses):
526
+ if not c or not _DOUBT.search(c):
527
+ continue
528
+ out |= L.content_words(c)
529
+ if i and (_TURNS_BACK.match(seps[i]) or re.match(r"\s*(?:which|that)\b", c, re.I)):
530
+ back = i - 1
531
+ while back > 0 and not clauses[back].strip():
532
+ back -= 1
533
+ out |= L.content_words(clauses[back])
534
+ return out
535
+
536
+
537
+ _ASSERTS = re.compile(r"\b(?:is|are|was|were)\b", re.I)
538
+
539
+
540
+ def _asserted_words(sent: str) -> list[str]:
541
+ """What a source sentence asserts, as stems in order: the words after its last copula ("every weight in the
542
+ inventory is in kilograms" asserts kilograms). A line with no copula ("item 2, weight 3 kg, value 26") is data,
543
+ not an assertion a doubt could turn on: a doubt about "the highest value" isn't one about that line's value."""
544
+ cop = list(_ASSERTS.finditer(sent))
545
+ if not cop:
546
+ return []
547
+ return [w for t in re.findall(r"[A-Za-z]+", sent[cop[-1].end():]) for w in L.content_words(t)]
548
+
549
+
550
+ def _affirmed_in(passage: str, stem: str) -> str:
551
+ """The sentence of ``passage`` that asserts a word with this stem without negating anything, or ''."""
552
+ for sent in re.split(r"(?<=[.!?;])\s+", passage or ""):
553
+ if stem in L.content_words(sent) and not _PASSAGE_NEGATION.search(sent):
554
+ return sent.strip()
555
+ return ""
556
+
557
+
558
+ def _doubted_in(passage: str, doubted: set[str]) -> tuple[list[str], str] | None:
559
+ """The words a plain (un-negated) sentence of ``passage`` asserts ("every weight ... is in kilograms") that the
560
+ statement doubts, and the sentence. Doubting a source's subject ("whether the container is available") is not
561
+ doubting what it says of it ("the container takes at most 36 kg")."""
562
+ if not doubted:
563
+ return None
564
+ for sent in re.split(r"(?<=[.!?;])\s+", passage or ""):
565
+ if _PASSAGE_NEGATION.search(sent):
566
+ continue
567
+ hit = list(dict.fromkeys(w for w in _asserted_words(sent) if w in doubted))
568
+ if hit:
569
+ return hit, sent.strip()
570
+ return None
571
+
572
+
573
+ def _source_contradicts(s: Snap, stmt: str, support: list[str]) -> list[Problem]:
574
+ denied, doubted = denied_words(stmt), doubted_words(stmt)
575
+ if not denied and not doubted:
576
+ return []
577
+ for src in support:
578
+ n = s.nodes.get(src)
579
+ if not n or n["type"] != "source":
580
+ continue
581
+ passage = str(n["body"].get("passage") or "")
582
+ doubt = _doubted_in(passage, doubted)
583
+ if doubt:
584
+ stems, sent = doubt
585
+ cite = str(n["body"].get("citation") or "").strip()
586
+ label = cite[:80] if cite else (n.get("code") or src)
587
+ return [Problem("SOURCE_CONTRADICTS",
588
+ f"the statement casts doubt on '{stems[-1]}', which a source it links as support ({label}) "
589
+ f"asserts: \"{sent[:200]}\". A source can't support a doubt about what it says: accept it, "
590
+ "or link it as a challenge and cite a source that says otherwise",
591
+ fix="accept what the source says, or link it as a challenge", words=stems)]
592
+ for stem in sorted(denied):
593
+ sent = _affirmed_in(passage, stem)
594
+ if sent:
595
+ cite = str(n["body"].get("citation") or "").strip()
596
+ label = cite[:80] if cite else (n.get("code") or src)
597
+ return [Problem("SOURCE_CONTRADICTS",
598
+ f"the statement denies '{stem}', but a source it links as support ({label}) says: "
599
+ f"\"{sent[:200]}\". A source that "
600
+ "says the opposite isn't support: correct the statement, or link the source as a "
601
+ "challenge",
602
+ fix="correct the statement, or link that source as a challenge", words=[stem])]
603
+ return []
604
+
605
+
298
606
  def _measured_claim(s: Snap, support: list[str]) -> bool:
299
607
  """A supporting claim that itself binds two sides: a comparison built on it inherits them."""
300
608
  for sid in support:
@@ -636,9 +944,12 @@ def claim_issues(s: Snap, body: dict, support: list[str], inv: str) -> list[Prob
636
944
  + ", ".join(named[:6]) + " — write the number in the sentence; it's bound to that key, "
637
945
  "so the record checks it", fix="replace each key with its number", words=named[:6]))
638
946
  problems += _absolutes(clean, scope, body, bound)
947
+ if not any(p.code == "COUNT_CONTRADICTS" for p in problems):
948
+ problems += _unreached(clean, bound)
639
949
  if kind not in ("lead", "convention"):
640
950
  problems += _comparisons(s, clean, bound, support)
641
951
  problems += _causal(s, clean, support)
952
+ problems += _source_contradicts(s, clean, support)
642
953
  problems += _exclusion(s, clean, kind, support, bound)
643
954
  if kind == "finding":
644
955
  problems += _uncorrected(s, clean, bound, inv)