hypgraph 0.7.20__tar.gz → 0.7.22__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.20 → hypgraph-0.7.22}/CHANGELOG.md +52 -0
  2. {hypgraph-0.7.20 → hypgraph-0.7.22}/PKG-INFO +1 -1
  3. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/RELEASE-READINESS.md +26 -0
  5. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/checks.py +226 -18
  7. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/refusals.py +28 -13
  8. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_contradictions.py +261 -1
  9. {hypgraph-0.7.20 → hypgraph-0.7.22}/.gitignore +0 -0
  10. {hypgraph-0.7.20 → hypgraph-0.7.22}/AGENTS.md +0 -0
  11. {hypgraph-0.7.20 → hypgraph-0.7.22}/CITATION.cff +0 -0
  12. {hypgraph-0.7.20 → hypgraph-0.7.22}/DISCLAIMER.md +0 -0
  13. {hypgraph-0.7.20 → hypgraph-0.7.22}/LICENSE +0 -0
  14. {hypgraph-0.7.20 → hypgraph-0.7.22}/NOTICE +0 -0
  15. {hypgraph-0.7.20 → hypgraph-0.7.22}/README.md +0 -0
  16. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  17. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  18. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/profile-workflows-plan.md +0 -0
  19. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/review-2026-10-24.md +0 -0
  20. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/site-plan.md +0 -0
  21. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/spec.md +0 -0
  22. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/verification.md +0 -0
  24. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/workflows/README.md +0 -0
  25. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/workflows/knowledge.md +0 -0
  26. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/workflows/optimization.md +0 -0
  27. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/workflows/scientific.md +0 -0
  28. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/workflows/software.md +0 -0
  29. {hypgraph-0.7.20 → hypgraph-0.7.22}/docs/workflows/trading.md +0 -0
  30. {hypgraph-0.7.20 → hypgraph-0.7.22}/examples/frameworks/README.md +0 -0
  31. {hypgraph-0.7.20 → hypgraph-0.7.22}/pyproject.toml +0 -0
  32. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/blind.py +0 -0
  34. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/bundle.py +0 -0
  35. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/demos/linked.py +0 -0
  40. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/language.py +0 -0
  43. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/mcp.py +0 -0
  44. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/plan.py +0 -0
  45. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/policy.py +0 -0
  46. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/profiles.py +0 -0
  47. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/references.py +0 -0
  48. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/report_check.py +0 -0
  49. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/schema.py +0 -0
  50. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/server.py +0 -0
  51. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/skill/SKILL.md +0 -0
  52. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/stats.py +0 -0
  53. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/store.py +0 -0
  54. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/__init__.py +0 -0
  55. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/atlas.css +0 -0
  56. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/atlas.js +0 -0
  57. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/hypgraph.css +0 -0
  58. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  59. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/hypgraph.js +0 -0
  60. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/hypgraph.svg +0 -0
  61. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/index.html +0 -0
  62. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  63. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  64. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  65. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  66. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  67. {hypgraph-0.7.20 → hypgraph-0.7.22}/src/hypgraph/workflow.py +0 -0
  68. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_accept_work_kind.py +0 -0
  69. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_anchoring.py +0 -0
  70. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_blind.py +0 -0
  71. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_blind_oracles.py +0 -0
  72. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_bound_support.py +0 -0
  73. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_bundle_telemetry.py +0 -0
  74. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_cancel_releases_lease.py +0 -0
  75. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_cited_and_failed_support.py +0 -0
  76. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_coord.py +0 -0
  77. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_d10.py +0 -0
  78. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_engine.py +0 -0
  79. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_engine_fixes.py +0 -0
  80. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_framework_examples.py +0 -0
  81. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_i18n.py +0 -0
  82. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_incremental.py +0 -0
  83. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_link_translate.py +0 -0
  84. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_linked_records.py +0 -0
  85. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_log_and_check.py +0 -0
  86. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_measure_questions.py +0 -0
  87. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_opposed_answers.py +0 -0
  88. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_option_lead.py +0 -0
  89. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_order_detail.py +0 -0
  90. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_profiles.py +0 -0
  91. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_profiles_041.py +0 -0
  92. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_questions_not_assertions.py +0 -0
  93. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_references.py +0 -0
  94. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_refusal_friction.py +0 -0
  95. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_refusal_results.py +0 -0
  96. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_refusals.py +0 -0
  97. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_relation_words.py +0 -0
  98. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_report_answers.py +0 -0
  99. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_rerunnable.py +0 -0
  100. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_restates_scale.py +0 -0
  101. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_server_auth.py +0 -0
  102. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_server_hosts.py +0 -0
  103. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_shape_refusals.py +0 -0
  104. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_stats_not_backtests.py +0 -0
  105. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_stats_values.py +0 -0
  106. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_surfaces.py +0 -0
  107. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_telemetry_stream.py +0 -0
  108. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_trust_boundary.py +0 -0
  109. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_ui_app.py +0 -0
  110. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_ui_engine.py +0 -0
  111. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_uncorrected_effect.py +0 -0
  112. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_unnamed_inputs.py +0 -0
  113. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_verify_complete.py +0 -0
  114. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_verify_lifecycle.py +0 -0
  115. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_verify_scope.py +0 -0
  116. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_verify_scoped_views.py +0 -0
  117. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_verify_trust.py +0 -0
  118. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_verify_views.py +0 -0
  119. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_versions_not_figures.py +0 -0
  120. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_vocabulary_friction.py +0 -0
  121. {hypgraph-0.7.20 → hypgraph-0.7.22}/tests/test_workflows.py +0 -0
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.22 — 2026-11-01: a label can't move a count, every part of a claim is checked, units rule each other out
4
+
5
+ Fixes from an independent audit of 0.7.21 (HG-021-01 to -04); each case below failed on 0.7.21 and is now a test.
6
+
7
+ - **The result says what a count is about, not its label** (HG-021-01). 0.7.21 read a count's figure from its
8
+ free-text name, so renaming the count of 2 beyond 93 "worth more than 140" moved it off "the highest value is 93",
9
+ and the false claim went in. An instrument can now state the figure with the count
10
+ (`{"k": 2, "n": 1024, "figure": 93}`); that figure is the only one, a measurement whose name says another is refused
11
+ as `MEASUREMENT_MISMATCH`, and the figure accounts for the number in the statement. Without one, a name that
12
+ disagrees with its key adds a figure and never replaces one: a count that finds exceptions refutes a claim at either
13
+ figure, and a count of none vouches only when both are the claim's.
14
+ - **Every absolute and every extreme in a claim is checked** (HG-021-02). A true "nothing worth more than 140 fits
15
+ among the 1,024 selections" ended the check, and "the highest value is 93" beside it was never looked at. One check
16
+ now runs per figure the absolutes are about, and the extreme is checked as well.
17
+ - **A source that states another unit of the same kind isn't support** (HG-021-03). Only a claim that denied or
18
+ doubted the source's word was compared with it, so "the recorded weights are in pounds", citing the note that every
19
+ recorded weight is in kilograms, went in. Units of mass, length, temperature and volume, and currencies, now rule
20
+ each other out when the claim and the source say them of the same thing (`SOURCE_CONTRADICTS`). A clause that only
21
+ converts ("in kilograms, about 2.2 pounds each") or states a condition ("if the weights are pounds") asserts
22
+ nothing. `conduct()` counts saying the ruled-out unit again as saying the claim again.
23
+ - **`shown_false` is not a lying rate** (HG-021-04). The `conduct()` docstring now says so: it adds up contradictions
24
+ by the record's own count and by a cited source, and a source can be wrong. Read `by_count`, `by_source`,
25
+ `corrected`, `reasserted` and `still_saying` instead.
26
+ - Limits, unchanged: contradictions in meaning outside the unit families aren't checked; a claim that drops the
27
+ citation or count that would contradict it is not checked against it; `strict_claims: false` runs no claim checks;
28
+ and a result without a stated figure still relies on its key to say what it counted.
29
+
30
+ ## 0.7.21 — 2026-11-01: counts at the claim's figure, scoped negation, conduct by kind
31
+
32
+ Fixes from an independent audit of 0.7.20 (HG-020-01 to -04); each case below failed on 0.7.20 and is now a test.
33
+
34
+ - **A count bears on a claim only at the figure the claim names.** A count says what lies beyond one figure
35
+ ("fitting selections worth more than 93"). In 0.7.20, binding a count of nothing beyond 140 beside the count of 2
36
+ beyond 93, in either order, let "nothing worth more than 93 fits" and "the highest value is 93" in (HG-020-01); and
37
+ a count beyond 93 refused the true "the highest value is 95" as `COUNT_CONTRADICTS`, which `conduct()` then held
38
+ against its author (HG-020-02). Now a count of exceptions at the claim's own figure refutes it whatever else is bound
39
+ beside it, and a count at another figure neither refutes nor rescues it. A count says its figure in its name
40
+ ("more than 93", "above 93"); a number at the end of a key counts only when it is a figure the claim names, since it
41
+ may just number repeated counts (`worth_more_2` is the second count, not one beyond 2). A count that names no figure
42
+ is read as before.
43
+ - **A source's "not X" negates X, not its whole sentence.** "Every recorded weight is in kilograms, not pounds" used
44
+ to be skipped for its "not", so it supported a claim of pounds (HG-020-03). A negation now reaches to the end of its
45
+ clause; "the weights were not recorded in kilograms" still asserts nothing about kilograms.
46
+ - **Conduct says which contradiction it was, and describes behaviour, not intent** (HG-020-04). `conduct()` adds
47
+ `by_count` (the record's own count contradicted the claim) and `by_source` (a source the author cited as support
48
+ says otherwise: the citation doesn't support the claim, which is not proof it is false, since a source can be
49
+ wrong). `shown_false` is still their total. Its sentence names each kind, and a last word that was refused now
50
+ reads "contradicted by the record". The docstring no longer reads intent into it: conduct is evidence for a
51
+ reviewer, not a verdict on anyone.
52
+ - Limits, unchanged and now stated: a claim that drops the citation or count that would contradict it is not checked
53
+ against it, and an investigation with `strict_claims: false` runs no claim checks.
54
+
3
55
  ## 0.7.20 — 2026-11-01: a claim the record can show false, and who keeps making it
4
56
 
5
57
  - **A claim the record's own counts show false is refused as false** (`COUNT_CONTRADICTS`). "The shortest route is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.20
3
+ Version: 0.7.22
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.20 (2026-11-01)
112
+ ## Status at 0.7.22 (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
@@ -88,6 +88,32 @@ was taken from a changelog without being checked against the code.*
88
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
89
  > suite is 450 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Released as 0.7.20.
90
90
 
91
+ > **Audit of 0.7.20 (2026-11-01), fixed for 0.7.21.** An independent audit found three false verdicts and a design
92
+ > point, each reproduced on the 0.7.20 wheel: a zero count at another figure cancelled the count that refutes a claim
93
+ > (HG-020-01), a count at another figure refuted a true extreme (HG-020-02), a "not" anywhere in a source sentence hid
94
+ > what it asserts (HG-020-03), and conduct counted a claim that goes against its own citation as shown false and read
95
+ > intent into it (HG-020-04). Counts now bear only at the figure the claim names, a negation reaches to the end of its
96
+ > clause, and conduct reports `by_count` / `by_source` and describes behaviour, not intent. Each case is a regression
97
+ > test written to fail on 0.7.20. Replaying 360 recorded adversarial trials gives the same standings as 0.7.20.
98
+ >
99
+ > **Where that stands.** #83 is merged with green CI (17 of 17 checks); from the 0.7.21 wheel in a fresh venv the
100
+ > suite is 466 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Released as 0.7.21.
101
+
102
+ > **Audit of 0.7.21 (2026-11-01), fixed for 0.7.22.** A second independent audit found three ways a false claim still
103
+ > got in, each reproduced on the 0.7.21 wheel, and a design point: renaming the count of 2 beyond 93 "worth more than
104
+ > 140" moved it off "the highest value is 93" (HG-021-01); a true "nothing worth more than 140 fits" beside that false
105
+ > extreme ended the check (HG-021-02); and "the recorded weights are in pounds", citing the note that they are in
106
+ > kilograms, was never compared with it because it denied nothing (HG-021-03). `shown_false` was open to being read as
107
+ > a lying rate (HG-021-04). A result can now state a count's figure, and a label can only widen what a count refutes;
108
+ > every absolute and extreme in a claim is checked; units of one kind rule each other out against a cited source; the
109
+ > conduct docstring says `shown_false` is not a lying rate. Each case is a regression test written to fail on 0.7.21.
110
+ > Replaying 360 recorded adversarial trials (1,900 entries): 5 entries change, all the units liar's; no honest or
111
+ > mistaken party's entry changes; two more parties still saying the false unit are named and no one new is misnamed.
112
+ >
113
+ > **Where that stands.** #85 is merged with green CI (17 of 17 checks); from the 0.7.22 wheel in a fresh venv the
114
+ > suite is 482 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. The audit asks for a fresh
115
+ > independent audit before anyone claims 0.7.22 resists these attack classes. Released as 0.7.22.
116
+
91
117
  ## Recommendation: CONDITIONAL GO
92
118
 
93
119
  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.20"
19
+ __version__ = "0.7.22"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -154,7 +154,7 @@ def derived_numbers(bound: list[tuple[dict, dict]]) -> tuple[list[float], list[f
154
154
  prim = []
155
155
  arms = []
156
156
  for m, rv in bound:
157
- plain += _nums(m) + _nums(rv)
157
+ plain += _nums(m) + _nums(rv) + [f for f in (stated_figure(rv),) if f is not None]
158
158
  a = _arm(rv)
159
159
  if a:
160
160
  k, n = a
@@ -199,7 +199,7 @@ def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]
199
199
  f"{m['result']} has no value '{m['key']}'"))
200
200
  continue
201
201
  bad = False
202
- for k in ("value", "k", "n"):
202
+ for k in ("value", "k", "n", "figure"):
203
203
  if k in m and k in rv and isinstance(m[k], (int, float)) and isinstance(rv[k], (int, float)):
204
204
  dec = len(str(m[k]).split(".")[1]) if "." in str(m[k]) else 0
205
205
  if round(float(rv[k]), dec) != round(float(m[k]), dec) and not (
@@ -207,6 +207,12 @@ def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]
207
207
  problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name') or i + 1}: says "
208
208
  f"{k}={m[k]} but result {m['result']} has {rv[k]}"))
209
209
  bad = True
210
+ said, f = _COUNT_FIGURE.search(str(m.get("name") or "")), stated_figure(rv)
211
+ if not bad and said and f is not None and float(said.group(1).replace(",", "")) != f:
212
+ problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: its name says "
213
+ f"{said.group(0).strip()}, but result {m['result']} counted '{m['key']}' against "
214
+ f"{_fmt(f)}", fix=f"name the count after {_fmt(f)}, the figure it was made against"))
215
+ bad = True
210
216
  if not bad:
211
217
  good.append((m, rv))
212
218
  return good, problems
@@ -253,18 +259,106 @@ def _enumerated(stmt: str, word: str) -> bool:
253
259
  _PROVEN = re.compile(r"^prov(?:es?|en|ed)$|^proof$", re.I)
254
260
 
255
261
 
262
+ # the figure a count is about: "fitting selections worth more than 93", "routes shorter than 26 km" (or, matching a
263
+ # figure the claim names, "worth_more_93"). A count of nothing beyond 140 says nothing about 93 (audit HG-020-01/02).
264
+ _COUNT_FIGURE = re.compile(r"\b(?:(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
265
+ r"slower|larger|smaller)\s+than|above|below|over|under|beyond|exceed\w*|beat\w*)\s+\$?"
266
+ r"(-?\d[\d,]*(?:\.\d+)?)", re.I)
267
+ _KEY_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
268
+ r"slower|larger|smaller|above|below|over|under|beyond|exceed\w*|beat\w*)(?:\s+than)?\s+"
269
+ r"(\d+(?:\.\d+)?)$", re.I)
270
+ # the figure an absolute names in its own clause: "nothing worth more than 93 fits", "no route is shorter than 26 km"
271
+ _CLAIM_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
272
+ r"slower|larger|smaller)\s+than\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\b(?:above|below|beyond|"
273
+ r"exceed(?:s|ing)?)\s+\$?(-?\d[\d,]*(?:\.\d+)?)", re.I)
274
+ _SUBCLAUSE = re.compile(r",(?!\d)|[;:!?()]|\.(?!\d)|\b(?:and|but|while|whereas|although|so)\b", re.I)
275
+
276
+
277
+ def stated_figure(rv: dict) -> float | None:
278
+ """The figure a count says it is about, written by the instrument beside it: {"k": 2, "n": 1024, "figure": 93} is
279
+ two found beyond 93."""
280
+ f = rv.get("figure")
281
+ return float(f) if isinstance(f, (int, float)) and not isinstance(f, bool) else None
282
+
283
+
284
+ def _count_figures(m: dict, rv: dict, figs: set[float] = frozenset()) -> set[float]:
285
+ """Every figure a bound count could be about; empty when nothing says. A figure the result states is the only one:
286
+ the instrument wrote it with the count. Otherwise the name's figure ("more than 93") and the key's: a key is an
287
+ identifier, and the number at its end may only tell repeated counts apart ("fitting_selections_worth_more_2" is
288
+ the second count, not one beyond 2), so it is read only when the claim names it or the name names another. The
289
+ author writes the name, so a name that disagrees with the key adds a figure and never replaces one: renaming the
290
+ count beyond 93 "worth more than 140" no longer moves it off "the highest is 93" (audit HG-021-01)."""
291
+ f = stated_figure(rv)
292
+ if f is not None:
293
+ return {f}
294
+ out: set[float] = set()
295
+ hit = _COUNT_FIGURE.search(str(m.get("name") or ""))
296
+ if hit:
297
+ out.add(float(hit.group(1).replace(",", "")))
298
+ hit = _KEY_FIGURE.search(str(m.get("key") or "").replace("_", " "))
299
+ if hit and (float(hit.group(1)) in figs or out):
300
+ out.add(float(hit.group(1)))
301
+ return out
302
+
303
+
304
+ def _absolute_figures(stmt: str, w: str) -> set[float]:
305
+ """The figures an absolute is about: one its own clause compares against ("nothing worth more than 93 fits"),
306
+ else the figures the statement's extremes name ("the highest is 93; nothing worth more fits")."""
307
+ figs: set[float] = set()
308
+ for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I):
309
+ end = next((x.start() for x in _SUBCLAUSE.finditer(stmt, m.end())), len(stmt))
310
+ figs |= {float((h.group(1) or h.group(2)).replace(",", "")) for h in _CLAIM_FIGURE.finditer(stmt[m.end():end])}
311
+ return figs or _extreme_figures(stmt)
312
+
313
+
314
+ def _bears(m: dict, rv: dict, a: tuple[int, int], figs: set[float]) -> bool:
315
+ """Does this count bear on a claim about ``figs``? One about another figure doesn't, whatever it finds: a zero
316
+ beyond 140 can't vouch for 93, and two beyond 93 can't refute 95. When it could be about more than one figure (a
317
+ name that disagrees with its key), a count that finds some bears if any of them is the claim's, and a count of
318
+ none or all only if every one is: a label can widen what a count refutes, never what it vouches for. A count that
319
+ names no figure, or a claim that names none, is read as before."""
320
+ c = _count_figures(m, rv, figs)
321
+ if not c or not figs:
322
+ return True
323
+ return bool(c & figs) if 0 < a[0] < a[1] else c <= figs
324
+
325
+
326
+ def _pinned(m: dict, rv: dict, a: tuple[int, int], figs: set[float]) -> bool:
327
+ """A count at the claim's own figure that finds exceptions: it refutes the claim whatever else is bound beside it."""
328
+ return 0 < a[0] < a[1] and bool(figs) and bool(_count_figures(m, rv, figs) & figs)
329
+
330
+
256
331
  def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
332
+ """Every absolute in the statement, and every extreme, checked on its own: a true "nothing worth more than 140
333
+ fits" used to end the check, and "the highest is 93" beside it went in unexamined (audit HG-021-02)."""
257
334
  words = [w for w in L.asserted(L.ABSOLUTE, stmt) if not _enumerated(stmt, w)]
258
335
  if any(str(m.get("key") or "").lower() in ("gap", "bound") for m, _rv in bound):
259
336
  words = [w for w in words if not _PROVEN.match(w)] # an optimum with its bound (gap 0) is proven
260
337
  words = [w for w in words if not _names_counted_set(stmt, w, bound)]
261
- if not words:
262
- return _superlative_contradicted(stmt, bound)
263
- w = words[0]
264
- arms = [(m, rv, _arm(rv)) for m, rv in bound if _arm(rv)]
338
+ out: list[Problem] = []
339
+ seen: set[frozenset] = set()
340
+ for w in words: # one check per figure the absolutes are about
341
+ figs = frozenset(_absolute_figures(stmt, w))
342
+ if figs not in seen:
343
+ seen.add(figs)
344
+ out += [p for p in _absolute(stmt, scope, body, bound, w, words, set(figs)) if p.text() not in
345
+ {q.text() for q in out}]
346
+ if not any(p.code == "COUNT_CONTRADICTS" for p in out):
347
+ out += _superlative_contradicted(stmt, bound)
348
+ return out
349
+
350
+
351
+ def _absolute(stmt: str, scope: str, body: dict, bound: list, w: str, words: list, figs: set[float]) -> list[Problem]:
352
+ arms = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and _bears(m, rv, a, figs)]
353
+ about = L.content_words(stmt)
354
+ # a count at the claim's own figure that finds exceptions refutes it, whatever else is bound beside it
355
+ pinned = [(m, a) for m, rv, a in arms if _pinned(m, rv, a, figs)
356
+ and L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about]
357
+ if pinned:
358
+ m, (k, n) = max(pinned, key=lambda x: x[1][1])
359
+ return [_count_contradicts(stmt, w, words, m, k, n)]
265
360
  exact = [(m, rv, a) for m, rv, a in arms if a[0] in (0, a[1])]
266
361
  if not exact:
267
- about = L.content_words(stmt)
268
362
  against = [(m, a) for m, _rv, a in arms if 0 < a[0] < a[1]
269
363
  and L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about]
270
364
  if against:
@@ -411,9 +505,17 @@ def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
411
505
  if not sup:
412
506
  return []
413
507
  about = L.content_words(stmt)
414
- rel = [(m, a) for m, rv in bound if (a := _arm(rv))
508
+ figs = _extreme_figures(stmt)
509
+ rel = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and _bears(m, rv, a, figs)
415
510
  and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
416
- or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " ")))]
511
+ or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
512
+ or stated_figure(rv) in figs)]
513
+ # a count beyond the extreme's own figure that finds some refutes it, whatever else is bound beside it
514
+ pinned = [(m, a) for m, rv, a in rel if _pinned(m, rv, a, figs) and not _qualified(stmt, *a)]
515
+ rel = [(m, a) for m, _rv, a in rel]
516
+ if pinned:
517
+ m, (k, n) = max(pinned, key=lambda x: x[1][1])
518
+ return [_count_contradicts(stmt, sup[0], sup, m, k, n)]
417
519
  if not rel or any(a[0] in (0, a[1]) for _m, a in rel):
418
520
  return []
419
521
  against = [(m, a) for m, a in rel if 0 < a[0] < a[1] and not _qualified(stmt, *a)]
@@ -487,6 +589,24 @@ def _subject_numbers(stmt: str, k: int, n: int) -> list[float]:
487
589
  _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
590
  re.I)
489
591
  _PASSAGE_NEGATION = re.compile(r"\b(not|no|never|none|nor|neither|without)\b|n't\b", re.I)
592
+ # where a negation's reach ends: "every weight is in kilograms, not pounds" negates pounds, not kilograms
593
+ _NEGATION_ENDS = re.compile(r",(?!\d)|[;:()]|\b(?:but|while|whereas|although|though|yet|instead)\b", re.I)
594
+
595
+
596
+ def _unnegated(sent: str) -> str:
597
+ """A source sentence less what its negations reach: from each "not" / "never" / "no" ... to the end of its clause.
598
+ "Every recorded weight is in kilograms, not pounds" still asserts kilograms; "the weights were not recorded in
599
+ kilograms" asserts nothing about kilograms. (0.7.20 dropped any sentence with a negation in it, so a source saying
600
+ "kilograms, not pounds" supported a claim of pounds; audit HG-020-03.)"""
601
+ out, i = [], 0
602
+ for m in _PASSAGE_NEGATION.finditer(sent):
603
+ if m.start() < i:
604
+ continue
605
+ out.append(sent[i:m.start()])
606
+ end = _NEGATION_ENDS.search(sent, m.end())
607
+ i = end.start() if end else len(sent)
608
+ out.append(sent[i:])
609
+ return "".join(out)
490
610
 
491
611
 
492
612
  def denied_words(stmt: str) -> set[str]:
@@ -548,31 +668,109 @@ def _asserted_words(sent: str) -> list[str]:
548
668
 
549
669
 
550
670
  def _affirmed_in(passage: str, stem: str) -> str:
551
- """The sentence of ``passage`` that asserts a word with this stem without negating anything, or ''."""
671
+ """The sentence of ``passage`` that asserts a word with this stem outside the reach of any negation, or ''."""
552
672
  for sent in re.split(r"(?<=[.!?;])\s+", passage or ""):
553
- if stem in L.content_words(sent) and not _PASSAGE_NEGATION.search(sent):
673
+ if stem in L.content_words(_unnegated(sent)):
554
674
  return sent.strip()
555
675
  return ""
556
676
 
557
677
 
558
678
  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")."""
679
+ """The words a sentence of ``passage`` asserts outside the reach of any negation ("every weight ... is in
680
+ kilograms, not pounds" asserts kilograms) that the statement doubts, and the sentence. Doubting a source's subject
681
+ ("whether the container is available") is not doubting what it says of it ("the container takes at most 36 kg")."""
562
682
  if not doubted:
563
683
  return None
564
684
  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))
685
+ hit = list(dict.fromkeys(w for w in _asserted_words(_unnegated(sent)) if w in doubted))
568
686
  if hit:
569
687
  return hit, sent.strip()
570
688
  return None
571
689
 
572
690
 
691
+ # units of one kind rule each other out: weights recorded in kilograms are not recorded in pounds. Only units and
692
+ # currencies, where the alternatives are known; a claim that contradicts a source in meaning outside these families
693
+ # isn't caught here (audit HG-021-03).
694
+ _UNIT_FAMILIES = {
695
+ "mass": {"kilogram": "kilogram kilograms kilo kilos kg kgs", "gram": "gram grams", "milligram": "milligram "
696
+ "milligrams mg", "tonne": "tonne tonnes ton tons", "pound": "pound pounds lb lbs",
697
+ "ounce": "ounce ounces oz"},
698
+ "length": {"metre": "metre metres meter meters", "kilometre": "kilometre kilometres kilometer kilometers km kms",
699
+ "centimetre": "centimetre centimetres centimeter centimeters cm", "millimetre": "millimetre millimetres "
700
+ "millimeter millimeters mm", "mile": "mile miles", "yard": "yard yards", "foot": "foot feet ft",
701
+ "inch": "inch inches"},
702
+ "temperature": {"celsius": "celsius centigrade", "fahrenheit": "fahrenheit", "kelvin": "kelvin"},
703
+ "volume": {"litre": "litre litres liter liters", "millilitre": "millilitre millilitres milliliter milliliters ml",
704
+ "gallon": "gallon gallons", "pint": "pint pints", "quart": "quart quarts"},
705
+ "currency": {"dollar": "dollar dollars usd", "euro": "euro euros eur", "pound": "pound pounds gbp sterling",
706
+ "yen": "yen jpy", "yuan": "yuan renminbi cny", "rupee": "rupee rupees inr", "franc": "franc francs chf",
707
+ "peso": "peso pesos"},
708
+ }
709
+ _UNIT_OF = {}
710
+ for _fam, _units in _UNIT_FAMILIES.items():
711
+ for _canon, _words in _units.items():
712
+ for _w in _words.split():
713
+ _UNIT_OF.setdefault(_w, set()).add((_fam, _canon))
714
+ _UNIT_CLAUSE = re.compile(r",(?!\d)|[;:()]|\.(?!\d)|\b(?:and|but|while|whereas|although|though|because|since|so)\b",
715
+ re.I)
716
+
717
+
718
+ def _plural(canon: str) -> str:
719
+ return {"foot": "feet", "inch": "inches", "celsius": "celsius", "fahrenheit": "fahrenheit", "kelvin": "kelvin",
720
+ "yen": "yen", "yuan": "yuan"}.get(canon, canon + "s")
721
+
722
+
723
+ def _units_in(text: str) -> set[tuple[str, str]]:
724
+ return {u for w in re.findall(r"[a-z]+", (text or "").lower()) for u in _UNIT_OF.get(w, ())}
725
+
726
+
727
+ _CONDITIONAL = re.compile(r"\b(?:if|whether|unless|suppos\w*|assum\w*|were they|had they)\b", re.I)
728
+
729
+
730
+ def _unit_assertions(text: str) -> list[tuple[set[str], set[tuple[str, str]], str]]:
731
+ """(subject words, units asserted of it, clause) for each clause with a copula, outside the reach of negation:
732
+ "the recorded weights are in pounds" says pounds of the recorded weights. A condition ("if the weights are
733
+ pounds") asserts nothing."""
734
+ out = []
735
+ for c in _UNIT_CLAUSE.split(_unnegated(text or "")):
736
+ cop = _ASSERTS.search(c)
737
+ if cop and not _CONDITIONAL.search(c[:cop.start()]) and (units := _units_in(c[cop.end():])):
738
+ out.append((L.content_words(c[:cop.start()]), units, c.strip()))
739
+ return out
740
+
741
+
742
+ def asserted_units(stmt: str) -> set[str]:
743
+ """The units (canonical names) a statement asserts of something: refusals.carried reads them to tell a unit
744
+ contradiction said again from a correction."""
745
+ return {canon for _subj, units, _c in _unit_assertions(stmt) for _fam, canon in units}
746
+
747
+
748
+ def _other_unit(passage: str, stmt: str) -> tuple[str, str, str] | None:
749
+ """(the statement's unit, the source's, the source sentence) when a sentence of ``passage`` says the statement's
750
+ subject is in another unit of the same kind, and no clause of the statement about that subject says the source's
751
+ unit too (a clause that only converts, "about 2.2 pounds each", asserts nothing; "kilograms, not pounds" is a
752
+ denial, checked above)."""
753
+ claimed = _unit_assertions(stmt)
754
+ if not claimed:
755
+ return None
756
+ for sent in re.split(r"(?<=[.!?;])\s+", passage or ""):
757
+ clean = _unnegated(sent)
758
+ cop = list(_ASSERTS.finditer(clean))
759
+ if not cop:
760
+ continue
761
+ subj, said = L.content_words(clean[:cop[-1].start()]), _units_in(clean[cop[-1].end():])
762
+ about = [units for csubj, units, _c in claimed if csubj & subj]
763
+ also = {u for units in about for u in units}
764
+ for fam, canon in sorted(also):
765
+ theirs = {c for f, c in said if f == fam}
766
+ if theirs and canon not in theirs and not {(fam, c) for c in theirs} & also:
767
+ return canon, sorted(theirs)[0], sent.strip()
768
+ return None
769
+
770
+
573
771
  def _source_contradicts(s: Snap, stmt: str, support: list[str]) -> list[Problem]:
574
772
  denied, doubted = denied_words(stmt), doubted_words(stmt)
575
- if not denied and not doubted:
773
+ if not denied and not doubted and not _unit_assertions(stmt):
576
774
  return []
577
775
  for src in support:
578
776
  n = s.nodes.get(src)
@@ -600,6 +798,16 @@ def _source_contradicts(s: Snap, stmt: str, support: list[str]) -> list[Problem]
600
798
  "says the opposite isn't support: correct the statement, or link the source as a "
601
799
  "challenge",
602
800
  fix="correct the statement, or link that source as a challenge", words=[stem])]
801
+ other = _other_unit(passage, stmt)
802
+ if other:
803
+ ours, theirs, sent = other
804
+ cite = str(n["body"].get("citation") or "").strip()
805
+ label = cite[:80] if cite else (n.get("code") or src)
806
+ return [Problem("SOURCE_CONTRADICTS",
807
+ f"the statement says {_plural(ours)}, but a source it links as support ({label}) says "
808
+ f"{_plural(theirs)}: \"{sent[:200]}\". One quantity isn't in two units of one kind, so that source isn't "
809
+ "support: correct the statement, or link the source as a challenge",
810
+ fix="correct the statement, or link that source as a challenge", words=[f"unit:{ours}"])]
603
811
  return []
604
812
 
605
813
 
@@ -27,11 +27,13 @@ chain, separate from the record's events (verify() on the record is unchanged; v
27
27
  says the false part and doesn't fix it either: the author moved off it. Logged once each time
28
28
  the author moves off it. ``ref`` is the refusal it leaves behind.
29
29
 
30
- conduct() is the per-author view of those: how often the record showed an author's claim false, how often the
30
+ conduct() is the per-author view of those: how often the record contradicted an author's claim, how often the
31
31
  author corrected it, how often it said the same thing again — and where it stands now: whether its LAST word on
32
- each claim shown false still says it. An honest mistake gets corrected when the contradiction is shown; still saying
33
- it at the end, after being shown, is the mark of a party arguing in bad faith. A party that repeated itself once
34
- and then moved off the claim is counted as having said it again, but not as still saying it.
32
+ each contradicted claim still says it. A party that repeated itself once and then moved off the claim is counted as
33
+ having said it again, but not as still saying it. It keeps the two kinds apart: the record's own count (``by_count``)
34
+ and a source the author cited as support that says otherwise (``by_source``) — the second shows the citation doesn't
35
+ support the claim, not that the claim is false, since a source can be wrong. Conduct describes what an author did
36
+ after being shown, not why: it is evidence for a reviewer to weigh, not a verdict on anyone's intent.
35
37
 
36
38
  report() turns it into the numbers worth watching: overclaims caught per author, how many were fixed and how many
37
39
  abandoned, which rules fire most — "your agents tried to say 'never' 14 times this week; 11 came back honest".
@@ -132,7 +134,7 @@ def carried(problems: list[dict], statement: str) -> bool:
132
134
  carried when one clause holds every figure it named (the '=…' words) again AND still asserts an extreme or an
133
135
  absolute about them ("the highest value is 93", "nothing beats 93"): mentioning the old figure in a correction
134
136
  ("1 of 82 routes is shorter than 26 km") is not saying it again. A source contradiction is carried when the
135
- statement still denies or doubts the same word."""
137
+ statement still denies or doubts the same word, or still says the unit the source ruled out."""
136
138
  from . import checks
137
139
  denied = checks.denied_words(statement or "")
138
140
  clauses = [c for c in _CLAUSE.split(statement or "") if c.strip()]
@@ -140,6 +142,9 @@ def carried(problems: list[dict], statement: str) -> bool:
140
142
  words = [str(w) for w in p.get("words") or []]
141
143
  if p.get("code") == "SOURCE_CONTRADICTS" and set(words) & (denied | checks.doubted_words(statement or "")):
142
144
  return True
145
+ units = {w[5:] for w in words if w.startswith("unit:")} # the unit a source ruled out
146
+ if p.get("code") == "SOURCE_CONTRADICTS" and units & checks.asserted_units(statement or ""):
147
+ return True
143
148
  figs = [float(w[1:]) for w in words if w.startswith("=") and w[1:].replace(".", "", 1).isdigit()]
144
149
  if p.get("code") != "COUNT_CONTRADICTS" or not figs:
145
150
  continue
@@ -333,14 +338,17 @@ def rows(g: "Graph", inv: str = "", author: str = "", since: str = "", limit: in
333
338
 
334
339
 
335
340
  def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
336
- """Per author: how often the record showed a claim of theirs false (its own count, or a source they cited, says
341
+ """Per author: how often the record contradicted a claim of theirs (its own count, or a source they cited, says
337
342
  the opposite), how often they then corrected it, and how often they said the same thing again.
338
- {author: {"shown_false", "corrected", "reasserted", "open", "still_saying", "moved_off", "sentence"}}. A
343
+ {author: {"shown_false", "by_count", "by_source", "corrected", "reasserted", "open", "still_saying", "moved_off",
344
+ "ends_false", "sentence"}}. ``shown_false`` is every claim contradicted, ``by_count`` / ``by_source`` how many of
345
+ them the record's own count or a cited source contradicted (one claim can be both). A
339
346
  re-assertion that was itself refused is not counted as a new time shown false. ``still_saying``: claims shown false
340
347
  whose author's last word on them (its last fix, repeat or move away, in log order) says them again;
341
348
  ``moved_off``: claims it last moved away from without a fix; ``ends_false``: the author's last write was itself
342
349
  shown false or said a contradicted claim again (a fresh false claim at the end counts, which ``still_saying``,
343
- being per claim, can't see)."""
350
+ being per claim, can't see). ``shown_false`` is not a rate of lying: it adds up two kinds of evidence, and a cited
351
+ source can itself be wrong. Read the parts, and what the author did next, not the total."""
344
352
  con = g._connect()
345
353
  q, args = "SELECT id, kind, author, ref, codes, detail FROM refusal_log WHERE 1=1", []
346
354
  for col, val in (("inv", inv), ("author", author)):
@@ -359,6 +367,7 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
359
367
  repeats.add(r)
360
368
  shown = {rid: a for rid, kind, a, _r, codes, _d in rows
361
369
  if kind == "refused" and rid not in repeats and set(json.loads(codes or "[]")) & set(CONTRADICTIONS)}
370
+ shown_codes = {rid: set(json.loads(codes or "[]")) for rid, _k, _a, _r, codes, _d in rows if rid in shown}
362
371
  root = {} # a repeat's own refusal → the refusal it repeated
363
372
  for _id, kind, _a, ref, _c, det in rows:
364
373
  if kind == "reasserted" and ref:
@@ -379,9 +388,11 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
379
388
  last[r] = kind
380
389
  out: dict[str, dict] = {}
381
390
  for rid, a in shown.items():
382
- o = out.setdefault(a, {"shown_false": 0, "corrected": 0, "reasserted": 0, "open": 0, "still_saying": 0,
383
- "moved_off": 0})
391
+ o = out.setdefault(a, {"shown_false": 0, "by_count": 0, "by_source": 0, "corrected": 0, "reasserted": 0,
392
+ "open": 0, "still_saying": 0, "moved_off": 0})
384
393
  o["shown_false"] += 1
394
+ o["by_count"] += "COUNT_CONTRADICTS" in shown_codes[rid]
395
+ o["by_source"] += "SOURCE_CONTRADICTS" in shown_codes[rid]
385
396
  fixed = any(k == "fixed" and r == rid for _i, k, _a, r, _c, _d in rows)
386
397
  again = sum(1 for _i, k, _a, r, _c, _d in rows if k == "reasserted" and r == rid)
387
398
  o["corrected"] += fixed
@@ -409,8 +420,12 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
409
420
  o["ends_false"] = any(k == "reasserted" or (k == "refused" and set(json.loads(c or "[]")) & set(CONTRADICTIONS))
410
421
  for k, c in rs) and not _accepted_after(con, a, inv, key)
411
422
  for a, o in out.items():
412
- n = o["shown_false"]
413
- o["sentence"] = (f"the record showed a claim of this author's false {n} time{'s' * (n != 1)}; "
423
+ c, s_ = o["by_count"], o["by_source"]
424
+ shown = "; ".join(
425
+ ([f"the record's own count contradicted a claim of this author's {c} time{'s' * (c != 1)}"] if c else [])
426
+ + ([f"{s_} claim{'s' * (s_ != 1)} of this author's went against a source it cited as support"]
427
+ if s_ else []))
428
+ o["sentence"] = (shown + "; "
414
429
  + (f"the author said it again {o['reasserted']} time{'s' * (o['reasserted'] != 1)} after "
415
430
  "being shown" if o["reasserted"] else "the author did not say it again")
416
431
  # "never corrected" only beside a repeat: dropping a claim for a different one is no repeat
@@ -418,7 +433,7 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
418
433
  if o["corrected"] else ", and never corrected it"
419
434
  if o["reasserted"] and (o["still_saying"] or o["ends_false"]) else "")
420
435
  + ("; its last word still says it" if o["still_saying"] else
421
- "; its last word was shown false" if o["ends_false"] else
436
+ "; its last word was contradicted by the record" if o["ends_false"] else
422
437
  "; its last word no longer says it" if o["reasserted"] else ""))
423
438
  return out
424
439