hypgraph 0.7.22__tar.gz → 0.7.23__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 (122) hide show
  1. {hypgraph-0.7.22 → hypgraph-0.7.23}/CHANGELOG.md +32 -0
  2. {hypgraph-0.7.22 → hypgraph-0.7.23}/PKG-INFO +1 -1
  3. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/RELEASE-READINESS.md +17 -0
  5. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/checks.py +144 -29
  7. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/language.py +5 -1
  8. hypgraph-0.7.23/tests/test_direction_and_units.py +167 -0
  9. {hypgraph-0.7.22 → hypgraph-0.7.23}/.gitignore +0 -0
  10. {hypgraph-0.7.22 → hypgraph-0.7.23}/AGENTS.md +0 -0
  11. {hypgraph-0.7.22 → hypgraph-0.7.23}/CITATION.cff +0 -0
  12. {hypgraph-0.7.22 → hypgraph-0.7.23}/DISCLAIMER.md +0 -0
  13. {hypgraph-0.7.22 → hypgraph-0.7.23}/LICENSE +0 -0
  14. {hypgraph-0.7.22 → hypgraph-0.7.23}/NOTICE +0 -0
  15. {hypgraph-0.7.22 → hypgraph-0.7.23}/README.md +0 -0
  16. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  17. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  18. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/profile-workflows-plan.md +0 -0
  19. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/review-2026-10-24.md +0 -0
  20. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/site-plan.md +0 -0
  21. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/spec.md +0 -0
  22. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/verification.md +0 -0
  24. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/workflows/README.md +0 -0
  25. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/workflows/knowledge.md +0 -0
  26. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/workflows/optimization.md +0 -0
  27. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/workflows/scientific.md +0 -0
  28. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/workflows/software.md +0 -0
  29. {hypgraph-0.7.22 → hypgraph-0.7.23}/docs/workflows/trading.md +0 -0
  30. {hypgraph-0.7.22 → hypgraph-0.7.23}/examples/frameworks/README.md +0 -0
  31. {hypgraph-0.7.22 → hypgraph-0.7.23}/pyproject.toml +0 -0
  32. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/blind.py +0 -0
  34. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/bundle.py +0 -0
  35. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/demos/linked.py +0 -0
  40. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/mcp.py +0 -0
  43. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/plan.py +0 -0
  44. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/policy.py +0 -0
  45. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/profiles.py +0 -0
  46. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/references.py +0 -0
  47. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/refusals.py +0 -0
  48. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/report_check.py +0 -0
  49. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/schema.py +0 -0
  50. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/server.py +0 -0
  51. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/skill/SKILL.md +0 -0
  52. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/stats.py +0 -0
  53. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/store.py +0 -0
  54. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/__init__.py +0 -0
  55. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/atlas.css +0 -0
  56. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/atlas.js +0 -0
  57. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/hypgraph.css +0 -0
  58. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  59. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/hypgraph.js +0 -0
  60. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/hypgraph.svg +0 -0
  61. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/index.html +0 -0
  62. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  63. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  64. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  65. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  66. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  67. {hypgraph-0.7.22 → hypgraph-0.7.23}/src/hypgraph/workflow.py +0 -0
  68. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_accept_work_kind.py +0 -0
  69. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_anchoring.py +0 -0
  70. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_blind.py +0 -0
  71. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_blind_oracles.py +0 -0
  72. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_bound_support.py +0 -0
  73. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_bundle_telemetry.py +0 -0
  74. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_cancel_releases_lease.py +0 -0
  75. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_cited_and_failed_support.py +0 -0
  76. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_contradictions.py +0 -0
  77. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_coord.py +0 -0
  78. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_d10.py +0 -0
  79. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_engine.py +0 -0
  80. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_engine_fixes.py +0 -0
  81. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_framework_examples.py +0 -0
  82. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_i18n.py +0 -0
  83. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_incremental.py +0 -0
  84. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_link_translate.py +0 -0
  85. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_linked_records.py +0 -0
  86. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_log_and_check.py +0 -0
  87. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_measure_questions.py +0 -0
  88. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_opposed_answers.py +0 -0
  89. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_option_lead.py +0 -0
  90. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_order_detail.py +0 -0
  91. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_profiles.py +0 -0
  92. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_profiles_041.py +0 -0
  93. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_questions_not_assertions.py +0 -0
  94. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_references.py +0 -0
  95. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_refusal_friction.py +0 -0
  96. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_refusal_results.py +0 -0
  97. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_refusals.py +0 -0
  98. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_relation_words.py +0 -0
  99. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_report_answers.py +0 -0
  100. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_rerunnable.py +0 -0
  101. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_restates_scale.py +0 -0
  102. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_server_auth.py +0 -0
  103. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_server_hosts.py +0 -0
  104. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_shape_refusals.py +0 -0
  105. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_stats_not_backtests.py +0 -0
  106. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_stats_values.py +0 -0
  107. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_surfaces.py +0 -0
  108. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_telemetry_stream.py +0 -0
  109. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_trust_boundary.py +0 -0
  110. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_ui_app.py +0 -0
  111. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_ui_engine.py +0 -0
  112. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_uncorrected_effect.py +0 -0
  113. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_unnamed_inputs.py +0 -0
  114. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_verify_complete.py +0 -0
  115. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_verify_lifecycle.py +0 -0
  116. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_verify_scope.py +0 -0
  117. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_verify_scoped_views.py +0 -0
  118. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_verify_trust.py +0 -0
  119. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_verify_views.py +0 -0
  120. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_versions_not_figures.py +0 -0
  121. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_vocabulary_friction.py +0 -0
  122. {hypgraph-0.7.22 → hypgraph-0.7.23}/tests/test_workflows.py +0 -0
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.23 — 2026-11-01: a count points one way, "no route is longer", what a unit is said of
4
+
5
+ Fixes from an independent audit of 0.7.22 (HG-022-01 to -05); each case below failed on 0.7.22 and is now a test.
6
+
7
+ - **A count points one way** (HG-022-01, -02). A count of routes longer than 26 km matched "the shortest route is
8
+ 26 km" on the figure, so the true claim was refused as `COUNT_CONTRADICTS`; and a count of none shorter than 26
9
+ backed "nothing is longer than 26 km". Comparisons now carry a direction on an axis ("longer" / "shorter",
10
+ "faster" / "slower"; "more", "above", "highest" meet any axis), and a count that points the other way from the
11
+ claim's exceptions neither refutes nor backs it. A result can state the comparison with the count
12
+ (`{"k": 2, "n": 3, "figure": 26, "op": ">"}`); that is the only direction, and a measurement whose name points the
13
+ other way is refused as `MEASUREMENT_MISMATCH`. Without one, a name and key that point opposite ways can widen what
14
+ a count refutes, never what it backs.
15
+ - **"No route is longer than 26 km" is an absolute** (HG-022-02), like "nothing is longer": it needs a count of
16
+ exceptions. Only a "no" that opens its clause, followed within three words by a copula and a comparison, is read
17
+ this way ("routes that visit no town twice" is not one).
18
+ - **A unit of record said with a verb** (HG-022-03). Only "is / are / was / were" stated a unit, so "recorded parcel
19
+ weights use pounds" went in beside the note that every recorded parcel weight is in kilograms. A short, fixed list
20
+ is read too: use, recorded / measured / expressed / given / listed / reported / denominated / kept / stated /
21
+ written / logged / entered / priced / quoted in. A hedge ("might be recorded in pounds") asserts nothing.
22
+ - **Two things that share a word are two things** (HG-022-04). "Passenger weights are recorded in pounds" was refused
23
+ against a source saying shipping weights are in kilograms, on the word "weights". A unit is now compared only when
24
+ the two subjects have the same head and don't each carry modifiers the other lacks entirely; every clause of the
25
+ source is read, and one that says the claim's unit of that thing means no contradiction.
26
+ - **A quantity is not a unit of record** (HG-022-05). "The parcel mass is about 2.2 pounds", citing "the parcel mass
27
+ is one kilogram", was refused: a unit after a number ("2.2 pounds", "one kilogram", "36 kg") is a quantity, and
28
+ isn't compared. The refusal no longer says a quantity can't be in two units; it says the source doesn't support the
29
+ statement.
30
+ - Limits: which thing a subject names and which unit a record uses are read from words, not typed; conversions
31
+ aren't checked, so "2.2 pounds" against "50 kilograms" goes in; verbs outside the list aren't read; the "no X is
32
+ longer" pattern is narrow ("there is no route longer" isn't read); and a direction is read only from the comparison
33
+ words above.
34
+
3
35
  ## 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
36
 
5
37
  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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.22
3
+ Version: 0.7.23
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.22 (2026-11-01)
112
+ ## Status at 0.7.23 (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
@@ -114,6 +114,23 @@ was taken from a changelog without being checked against the code.*
114
114
  > suite is 482 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. The audit asks for a fresh
115
115
  > independent audit before anyone claims 0.7.22 resists these attack classes. Released as 0.7.22.
116
116
 
117
+ > **Audit of 0.7.22 (2026-11-01), fixed for 0.7.23.** A third independent audit found five cases, each reproduced on
118
+ > the 0.7.22 wheel. Two let a count stand for its opposite: a count of routes longer than 26 km refused the true "the
119
+ > shortest route is 26 km" (HG-022-01), and none shorter than 26 backed "nothing / no route is longer than 26 km"
120
+ > (HG-022-02). One let a unit claim in: "recorded parcel weights use pounds" stated no copula (HG-022-03). Two made
121
+ > false accusations: "passenger weights" was matched with "shipping weights" on a shared word (HG-022-04), and a
122
+ > quantity converted to another unit was read as a unit of record (HG-022-05). Comparisons now carry a direction, and
123
+ > a result can state its comparison (`op`); "no X is longer than…" opening a clause is an absolute; a short fixed list
124
+ > of verbs states a unit; units are compared only between subjects with the same head and compatible modifiers; a unit
125
+ > after a number is a quantity. Each case is a regression test written to fail on 0.7.22. Replaying the 1,900
126
+ > recorded adversarial entries names exactly the same parties as 0.7.22; one repeated false unit claim is no longer
127
+ > refused, because 0.7.22 had refused it on a shared word and a quantity, and its author is still named.
128
+ >
129
+ > **Where that stands.** #87 is merged with green CI (17 of 17 checks); from the 0.7.23 wheel in a fresh venv the
130
+ > suite is 504 passed, and hypswarm (118 passed) and hypmesh (37 passed) pass on it. Which thing a subject names and
131
+ > which unit a record uses are still read from words, not typed; a fresh independent audit should precede any claim
132
+ > that 0.7.23 resists these attack classes. Released as 0.7.23.
133
+
117
134
  ## Recommendation: CONDITIONAL GO
118
135
 
119
136
  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.22"
19
+ __version__ = "0.7.23"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -213,6 +213,12 @@ def _bound(s: Snap, meas: list) -> tuple[list[tuple[dict, dict]], list[Problem]]
213
213
  f"{said.group(0).strip()}, but result {m['result']} counted '{m['key']}' against "
214
214
  f"{_fmt(f)}", fix=f"name the count after {_fmt(f)}, the figure it was made against"))
215
215
  bad = True
216
+ op, named = stated_op(rv), _directions(str(m.get("name") or ""))
217
+ if not bad and op and any(_opposed(d, ("", _OPS[op])) for d in named):
218
+ problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: its name points the other "
219
+ f"way from what result {m['result']} counted for '{m['key']}' ({op})",
220
+ fix=f"name the count after the comparison it made ({op})"))
221
+ bad = True
216
222
  if not bad:
217
223
  good.append((m, rv))
218
224
  return good, problems
@@ -301,6 +307,74 @@ def _count_figures(m: dict, rv: dict, figs: set[float] = frozenset()) -> set[flo
301
307
  return out
302
308
 
303
309
 
310
+ # which way a comparison points, as (axis, sign): "longer" and "longest" point up the length axis, "shorter" and
311
+ # "shortest" down it; "more", "above", "highest" point up with no axis of their own, so they meet any axis. A count of
312
+ # routes longer than 26 km says nothing against "the shortest is 26 km" (audit HG-022-01), and none shorter than 26
313
+ # doesn't back "nothing is longer than 26" (HG-022-02).
314
+ _DIRECTION_WORDS = {
315
+ "": {1: "more greater higher larger bigger above over exceed exceeds exceeding exceeded most highest largest "
316
+ "greatest biggest maximum max top",
317
+ -1: "less fewer lower smaller below under least lowest smallest fewest minimum min"},
318
+ "long": {1: "longer longest", -1: "shorter shortest"},
319
+ "good": {1: "better best", -1: "worse worst"},
320
+ "fast": {1: "faster fastest", -1: "slower slowest"},
321
+ "cost": {1: "dearer dearest", -1: "cheaper cheapest"},
322
+ "time": {1: "later latest", -1: "earlier earliest"},
323
+ }
324
+ _DIRECTION_OF = {w: (axis, sign) for axis, signs in _DIRECTION_WORDS.items() for sign, ws in signs.items()
325
+ for w in ws.split()}
326
+ _OPS = {">": 1, ">=": 1, "<": -1, "<=": -1}
327
+
328
+
329
+ def _directions(text: str) -> set[tuple[str, int]]:
330
+ return {_DIRECTION_OF[w] for w in re.findall(r"[a-z]+", (text or "").lower()) if w in _DIRECTION_OF}
331
+
332
+
333
+ def _opposed(a: tuple[str, int], b: tuple[str, int]) -> bool:
334
+ """Do these point opposite ways on one axis? An axis-free word ("more") meets any axis; two different axes
335
+ ("longer", "faster") can't be compared, so they aren't opposed."""
336
+ return a[1] != b[1] and (a[0] == b[0] or not a[0] or not b[0])
337
+
338
+
339
+ def stated_op(rv: dict) -> str | None:
340
+ """The comparison a count says it made, written by the instrument beside it: {"k": 2, "n": 3, "figure": 26,
341
+ "op": ">"} is two found above 26."""
342
+ op = rv.get("op")
343
+ return op if op in _OPS else None
344
+
345
+
346
+ def _count_directions(m: dict, rv: dict) -> set[tuple[str, int]]:
347
+ """Every way a bound count could point; empty when nothing says. A comparison the result states is the only one.
348
+ Otherwise its name's and its key's: as with its figure, the author writes the name, so a name that points the
349
+ other way from the key adds a direction and never replaces one."""
350
+ op = stated_op(rv)
351
+ if op:
352
+ return {("", _OPS[op])}
353
+ return _directions(str(m.get("name") or "")) | _directions(str(m.get("key") or "").replace("_", " "))
354
+
355
+
356
+ def _claim_directions(stmt: str, w: str = "") -> set[tuple[str, int]]:
357
+ """Which way the exceptions to a claim point: those the absolute ``w``'s own clause compares against ("nothing
358
+ longer than 26 km" — longer), else those beyond its extremes ("the shortest route is 26 km" — shorter)."""
359
+ out: set[tuple[str, int]] = set()
360
+ for m in re.finditer(r"\b" + re.escape(w) + r"\b", stmt, re.I) if w else ():
361
+ end = next((x.start() for x in _SUBCLAUSE.finditer(stmt, m.end())), len(stmt))
362
+ out |= {d for h in _CLAIM_FIGURE.finditer(stmt[m.end():end]) for d in _directions(h.group(0))}
363
+ return out or {d for x in _extremes(stmt) for d in _directions(x)}
364
+
365
+
366
+ def _points(m: dict, rv: dict, a: tuple[int, int], dirs: set[tuple[str, int]]) -> bool:
367
+ """Does this count point the way the claim's exceptions do? One that points the other way can't refute the claim
368
+ or back it. When it could point either way (a name against its key), a count that finds some may still refute,
369
+ and a count of none or all backs the claim only if no way it could point is the other way. A count, or a claim,
370
+ whose direction can't be told is read as before."""
371
+ c = _count_directions(m, rv)
372
+ if not c or not dirs:
373
+ return True
374
+ against = {d for d in c if all(_opposed(d, x) for x in dirs)}
375
+ return against != c if 0 < a[0] < a[1] else not against
376
+
377
+
304
378
  def _absolute_figures(stmt: str, w: str) -> set[float]:
305
379
  """The figures an absolute is about: one its own clause compares against ("nothing worth more than 93 fits"),
306
380
  else the figures the statement's extremes name ("the highest is 93; nothing worth more fits")."""
@@ -311,12 +385,15 @@ def _absolute_figures(stmt: str, w: str) -> set[float]:
311
385
  return figs or _extreme_figures(stmt)
312
386
 
313
387
 
314
- def _bears(m: dict, rv: dict, a: tuple[int, int], figs: set[float]) -> bool:
388
+ def _bears(m: dict, rv: dict, a: tuple[int, int], figs: set[float],
389
+ dirs: set[tuple[str, int]] = frozenset()) -> bool:
315
390
  """Does this count bear on a claim about ``figs``? One about another figure doesn't, whatever it finds: a zero
316
391
  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
392
  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
393
  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
394
  names no figure, or a claim that names none, is read as before."""
395
+ if not _points(m, rv, a, dirs):
396
+ return False
320
397
  c = _count_figures(m, rv, figs)
321
398
  if not c or not figs:
322
399
  return True
@@ -349,7 +426,8 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
349
426
 
350
427
 
351
428
  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)]
429
+ dirs = _claim_directions(stmt, w)
430
+ arms = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and _bears(m, rv, a, figs, dirs)]
353
431
  about = L.content_words(stmt)
354
432
  # a count at the claim's own figure that finds exceptions refutes it, whatever else is bound beside it
355
433
  pinned = [(m, a) for m, rv, a in arms if _pinned(m, rv, a, figs)
@@ -506,7 +584,8 @@ def _superlative_contradicted(stmt: str, bound: list) -> list[Problem]:
506
584
  return []
507
585
  about = L.content_words(stmt)
508
586
  figs = _extreme_figures(stmt)
509
- rel = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and _bears(m, rv, a, figs)
587
+ dirs = _claim_directions(stmt)
588
+ rel = [(m, rv, a) for m, rv in bound if (a := _arm(rv)) and _bears(m, rv, a, figs, dirs)
510
589
  and (L.content_words(f"{m.get('name', '')} {m.get('key', '')}") & about
511
590
  or _BEYOND.search(f"{m.get('name', '')} {m.get('key', '')}".replace("_", " "))
512
591
  or stated_figure(rv) in figs)]
@@ -720,22 +799,60 @@ def _plural(canon: str) -> str:
720
799
  "yen": "yen", "yuan": "yuan"}.get(canon, canon + "s")
721
800
 
722
801
 
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, ())}
802
+ # a unit after a number is a quantity ("about 2.2 pounds", "one kilogram", "36 kg"), not the unit something is
803
+ # recorded in: a quantity converted into another unit says nothing against a source's unit (audit HG-022-05)
804
+ _QUANTITY = re.compile(r"(?:\d|\b(?:a|an|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|twenty|thirty|"
805
+ r"forty|fifty|hundred|hundreds|thousand|thousands|million|millions|dozen|dozens|half|quarter|"
806
+ r"few|several|couple)\b)[\s-]*(?:of\s+)?$", re.I)
725
807
 
726
808
 
727
- _CONDITIONAL = re.compile(r"\b(?:if|whether|unless|suppos\w*|assum\w*|were they|had they)\b", re.I)
809
+ def _units_in(text: str) -> set[tuple[str, str]]:
810
+ """The units of record ``text`` names: a unit a number comes before is a quantity and doesn't count."""
811
+ text = (text or "").lower()
812
+ return {u for m in re.finditer(r"[a-z]+", text) if m.group(0) in _UNIT_OF
813
+ and not _QUANTITY.search(text[:m.start()]) for u in _UNIT_OF[m.group(0)]}
728
814
 
729
815
 
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."""
816
+ _CONDITIONAL = re.compile(r"\b(?:if|whether|unless|suppos\w*|assum\w*|were they|had they)\b", re.I)
817
+ # what states the unit something is in: a copula, or one of a short, fixed list of verbs ("recorded parcel weights use
818
+ # pounds", "the weights are given in pounds"; audit HG-022-03). Prose outside the list isn't read.
819
+ _RECORDS = re.compile(r"\b(?:is|are|was|were|uses?|used|using|(?:recorded|measured|expressed|given|listed|reported|"
820
+ r"denominated|kept|stated|written|logged|entered|priced|quoted)\s+(?:in|as|using))\b", re.I)
821
+ _HEDGED = re.compile(r"\b(?:may|might|could|perhaps|possibly)\b", re.I)
822
+ _DETERMINERS = set("a an the all every each any some these those this that its their our his her both".split())
823
+ _PREPOSITIONS = set("of in for from on at by with to among across within under over into per".split())
824
+
825
+
826
+ def _subject(text: str) -> tuple[str, frozenset[str]]:
827
+ """What a unit is said of, as (head, modifiers): "every recorded parcel weight in the register" is (weight,
828
+ {recorded, parcel}). Determiners go, and the subject ends at its first preposition."""
829
+ words = []
830
+ for w in re.findall(r"[a-z]+", (text or "").lower()):
831
+ if w in _PREPOSITIONS and words:
832
+ break
833
+ if w not in _DETERMINERS:
834
+ words += sorted(L.content_words(w))
835
+ return (words[-1], frozenset(words[:-1])) if words else ("", frozenset())
836
+
837
+
838
+ def _same_thing(a: tuple[str, frozenset[str]], b: tuple[str, frozenset[str]]) -> bool:
839
+ """Could these be one thing? Their heads must match, and two subjects each named for something the other isn't
840
+ ("passenger weights", "shipping weights") are two things (audit HG-022-04). A bare "weights" may be either."""
841
+ return bool(a[0]) and a[0] == b[0] and (not a[1] or not b[1] or bool(a[1] & b[1]))
842
+
843
+
844
+ def _unit_assertions(text: str) -> list[tuple[tuple[str, frozenset[str]], set[tuple[str, str]], str]]:
845
+ """(subject, units of record asserted of it, clause) for each clause that states one, outside the reach of
846
+ negation: "the recorded weights are in pounds" says pounds of the recorded weights. A condition ("if the weights
847
+ are pounds") or a hedge ("the weights might be recorded in pounds") asserts nothing."""
734
848
  out = []
735
849
  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()))
850
+ verb = _RECORDS.search(c)
851
+ if not verb or _CONDITIONAL.search(c[:verb.start()]) or _HEDGED.search(c[:verb.start()]):
852
+ continue
853
+ subj = _subject(c[:verb.start()])
854
+ if subj[0] and (units := _units_in(c[verb.end():])):
855
+ out.append((subj, units, c.strip()))
739
856
  return out
740
857
 
741
858
 
@@ -746,25 +863,23 @@ def asserted_units(stmt: str) -> set[str]:
746
863
 
747
864
 
748
865
  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)."""
866
+ """(the statement's unit, the source's, the source sentence) when the source says the statement's subject is in
867
+ another unit of the same kind, nowhere says it is in the statement's, and no clause of the statement about that
868
+ subject says the source's unit too ("kilograms, not pounds" is a denial, checked above)."""
753
869
  claimed = _unit_assertions(stmt)
754
870
  if not claimed:
755
871
  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):
872
+ given = [(subj, units, sent.strip()) for sent in re.split(r"(?<=[.!?;])\s+", passage or "")
873
+ for subj, units, _c in _unit_assertions(sent)]
874
+ for csubj, cunits, _c in claimed:
875
+ theirs_all = [(units, sent) for subj, units, sent in given if _same_thing(csubj, subj)]
876
+ said = {u for units, _s in theirs_all for u in units}
877
+ also = {u for subj, units, _c in claimed if _same_thing(csubj, subj) for u in units}
878
+ for fam, canon in sorted(cunits):
765
879
  theirs = {c for f, c in said if f == fam}
766
880
  if theirs and canon not in theirs and not {(fam, c) for c in theirs} & also:
767
- return canon, sorted(theirs)[0], sent.strip()
881
+ sent = next(s for units, s in theirs_all if any(f == fam for f, _c in units))
882
+ return canon, sorted(theirs)[0], sent
768
883
  return None
769
884
 
770
885
 
@@ -805,8 +920,8 @@ def _source_contradicts(s: Snap, stmt: str, support: list[str]) -> list[Problem]
805
920
  label = cite[:80] if cite else (n.get("code") or src)
806
921
  return [Problem("SOURCE_CONTRADICTS",
807
922
  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",
923
+ f"{_plural(theirs)} for the same thing: \"{sent[:200]}\". That source doesn't support the statement: "
924
+ "correct the statement, or link the source as a challenge",
810
925
  fix="correct the statement, or link that source as a challenge", words=[f"unit:{ours}"])]
811
926
  return []
812
927
 
@@ -18,7 +18,11 @@ ABSOLUTE = re.compile(
18
18
  r"\b(never|always|all|every|none|nothing|no one|nobody|impossible|cannot|can't|proves?|proven|proof|certainly|"
19
19
  r"certain|guaranteed?|guarantees|eliminat(?:es|ed|ing)|entirely|completely|totally|universally|invariably|"
20
20
  r"consistently|without exception|perfect(?:ly)?|solves?|solved|definitively|conclusively|undeniably|"
21
- r"zero(?!-))\b|(?<![\d.])100\s*%", re.I)
21
+ r"zero(?!-)"
22
+ # "no route is longer than 26 km": a universal negative comparison, the same claim as "nothing is longer"; only
23
+ # where "no" opens its clause ("routes that visit no town twice" is not one)
24
+ r"|(?<![a-z] )no(?=\s+(?:[a-z]+\s+){1,3}?(?:is|are|was|were)\s+(?:any\s+)?(?:longer|shorter|more|less|fewer|greater|higher|"
25
+ r"lower|larger|smaller|better|worse|faster|slower|cheaper|above|below|over|under)\b))\b|(?<![\d.])100\s*%", re.I)
22
26
  # a superlative names an extreme ("the highest value is 93"): a count of anything beyond it contradicts it
23
27
  SUPERLATIVE = re.compile(
24
28
  r"\b(highest|lowest|largest|smallest|shortest|longest|greatest|fewest|best|worst|maximum|minimum|optimal|"
@@ -0,0 +1,167 @@
1
+ """Which way a count points, and what a unit says (audit of 0.7.22, HG-022-01..05).
2
+
3
+ A count of routes longer than 26 km says nothing against "the shortest route is 26 km", and a count of none shorter
4
+ than 26 doesn't back "nothing is longer than 26". A source's unit of record is compared only with the same thing's
5
+ unit of record: not with another thing that shares a word, and not with a quantity converted into another unit."""
6
+ import pytest
7
+
8
+ from hypgraph import Graph, GraphError, Policy
9
+
10
+ ROUTES = "The three surveyed routes, of 26, 31 and 35 km."
11
+
12
+
13
+ def record(values):
14
+ g = Graph(policy=Policy(reviewers={"critic"}))
15
+ inv = g.author("curator").investigation("Routes", "Which surveyed route is shortest?")["id"]
16
+ lab = g.author("lab")
17
+ run = lab.add("run", inv, kind="lab", state="succeeded", name="Measured the surveyed routes.")
18
+ r = lab.add("result", inv, kind="computed", outcome="positive", summary="Route counts against 26 km.",
19
+ values=values, links=[{"rel": "produced", "from": run["id"]}])["id"]
20
+ return g, inv, r
21
+
22
+
23
+ def source(g, inv, passage):
24
+ return g.author("curator").add("source", inv, citation="Records note", passage=passage, kind="primary")["id"]
25
+
26
+
27
+ def write(g, inv, statement, links, meas=(), scope=ROUTES, who="a"):
28
+ return g.author(who).add("claim", inv, statement=statement, scope=scope, measurements=list(meas),
29
+ links=[{"rel": "supports", "from": x, "why": "Cited for the claim."} for x in links])
30
+
31
+
32
+ def codes(e) -> list[str]:
33
+ return [p["code"] for p in e.value.problems]
34
+
35
+
36
+ def count(r, key, name, k, n=3):
37
+ return {"name": name, "result": r, "key": key, "k": k, "n": n}
38
+
39
+
40
+ SHORTEST = {"name": "shortest route km", "key": "shortest_km", "value": 26}
41
+
42
+
43
+ @pytest.mark.parametrize("typed", [{}, {"figure": 26}, {"figure": 26, "op": ">"}])
44
+ def test_a_count_the_other_way_does_not_refute_a_true_extreme(typed):
45
+ """HG-022-01: the true "the shortest route is 26 km", with 2 of 3 routes longer than 26 bound, was refused as
46
+ COUNT_CONTRADICTS: the count matched the figure, and nothing asked which way it pointed."""
47
+ g, inv, r = record({"longer_than_26": {"k": 2, "n": 3, **typed}, "shortest_km": 26})
48
+ write(g, inv, "The shortest route is 26 km.", [r],
49
+ [count(r, "longer_than_26", "routes longer than 26 km", 2), {**SHORTEST, "result": r}])
50
+ assert "a" not in g.conduct(inv)
51
+ # the count the claim needs still decides: 1 of 3 shorter than 31 refutes "the shortest is 31"
52
+ g, inv, r = record({"shorter_than_31": {"k": 1, "n": 3, **({"figure": 31} if typed else {})}, "x_km": 31})
53
+ with pytest.raises(GraphError) as e:
54
+ write(g, inv, "The shortest route is 31 km.", [r],
55
+ [count(r, "shorter_than_31", "routes shorter than 31 km", 1),
56
+ {"name": "route km", "result": r, "key": "x_km", "value": 31}])
57
+ assert "COUNT_CONTRADICTS" in codes(e)
58
+
59
+
60
+ @pytest.mark.parametrize("typed", [{}, {"figure": 26}, {"figure": 26, "op": "<"}])
61
+ @pytest.mark.parametrize("statement", ["Nothing is longer than 26 km among the 3 routes.",
62
+ "No route is longer than 26 km among the 3 routes."])
63
+ def test_a_count_the_other_way_does_not_back_an_absolute(typed, statement):
64
+ """HG-022-02: none of 3 routes shorter than 26 backed "nothing is longer than 26 km among the 3 routes". It is
65
+ the wrong count: the claim goes in only with a count of routes longer than 26, which here finds 2."""
66
+ g, inv, r = record({"shorter_than_26": {"k": 0, "n": 3, **typed}, "longer_than_26": {"k": 2, "n": 3}})
67
+ with pytest.raises(GraphError) as e:
68
+ write(g, inv, statement, [r], [count(r, "shorter_than_26", "routes shorter than 26 km", 0)])
69
+ assert "ABSOLUTE_WITHOUT_COUNT" in codes(e) and "COUNT_CONTRADICTS" not in codes(e)
70
+ with pytest.raises(GraphError) as e:
71
+ write(g, inv, statement, [r], [count(r, "shorter_than_26", "routes shorter than 26 km", 0),
72
+ count(r, "longer_than_26", "routes longer than 26 km", 2)])
73
+ assert "COUNT_CONTRADICTS" in codes(e)
74
+ # the true absolute with its own count goes in
75
+ write(g, inv, "No route is shorter than 26 km among the 3 routes.", [r],
76
+ [count(r, "shorter_than_26", "routes shorter than 26 km", 0)])
77
+
78
+
79
+ def test_a_no_inside_a_phrase_is_not_an_absolute():
80
+ """"no route is longer" opens its clause with "no"; "routes that visit no town twice" only describes them. A true
81
+ count sentence with that phrase in it goes in."""
82
+ g, inv, r = record({"shorter_than_31": {"k": 1, "n": 3}})
83
+ write(g, inv, "One of the 3 routes that visit no town twice is shorter than 31 km.", [r],
84
+ [count(r, "shorter_than_31", "routes shorter than 31 km", 1)])
85
+ assert "a" not in g.conduct(inv)
86
+
87
+
88
+ def test_a_stated_direction_decides_and_a_label_cannot_flip_it():
89
+ """A result can state which way its count points ({"op": "<"}); a measurement whose name points the other way is
90
+ refused. Without one, a name and key that point opposite ways can widen what the count refutes, never what it
91
+ backs."""
92
+ g, inv, r = record({"shorter_than_26": {"k": 0, "n": 3, "figure": 26, "op": "<"},
93
+ "beyond_26": {"k": 0, "n": 3}})
94
+ with pytest.raises(GraphError) as e:
95
+ write(g, inv, "Nothing is longer than 26 km among the 3 routes.", [r],
96
+ [count(r, "shorter_than_26", "routes longer than 26 km", 0)])
97
+ assert "MEASUREMENT_MISMATCH" in codes(e) and "<" in str(e.value)
98
+ # untyped: the name says longer, the key says shorter: a zero can't back "nothing longer"
99
+ g, inv, r = record({"shorter_than_26": {"k": 0, "n": 3}})
100
+ with pytest.raises(GraphError) as e:
101
+ write(g, inv, "Nothing is longer than 26 km among the 3 routes.", [r],
102
+ [count(r, "shorter_than_26", "routes longer than 26 km", 0)])
103
+ assert "ABSOLUTE_WITHOUT_COUNT" in codes(e)
104
+
105
+
106
+ def test_the_direction_of_words():
107
+ from hypgraph.checks import _claim_directions, _count_directions, _opposed
108
+ d = lambda name, key="x", rv=None: _count_directions({"name": name, "key": key}, rv or {}) # noqa: E731
109
+ assert d("routes longer than 26 km") == {("long", 1)}
110
+ assert d("routes shorter than 26 km") == {("long", -1)}
111
+ assert d("fitting selections worth more than 93") == {("", 1)}
112
+ assert d("fitting selections worth exactly 93") == set()
113
+ assert d("x", rv={"op": "<="}) == {("", -1)}
114
+ assert d("routes longer than 26 km", "shorter_than_26") == {("long", 1), ("long", -1)}
115
+ assert _claim_directions("The shortest route is 26 km.") == {("long", -1)}
116
+ assert _claim_directions("The highest total value that fits is 93.") == {("", 1)}
117
+ assert _claim_directions("Nothing worth more than 93 fits.", "Nothing") == {("", 1)}
118
+ assert _opposed(("long", 1), ("long", -1)) and _opposed(("", 1), ("long", -1))
119
+ assert not _opposed(("long", 1), ("fast", -1)) and not _opposed(("", 1), ("", 1))
120
+
121
+
122
+ UNITS = "Every recorded parcel weight is in kilograms, not pounds."
123
+
124
+
125
+ @pytest.mark.parametrize("statement", ["Recorded parcel weights use pounds.",
126
+ "The recorded parcel weights are given in pounds.",
127
+ "Recorded parcel weights were logged in pounds.",
128
+ "The recorded parcel weights are expressed in pounds."])
129
+ def test_a_unit_of_record_said_without_a_copula(statement):
130
+ """HG-022-03: only "is / are / was / were" said a unit, so "recorded parcel weights use pounds" went in beside
131
+ the kilograms note. A short, fixed list of verbs that state a unit of record is read too."""
132
+ g, inv, _r = record({"x": {"k": 0, "n": 3}})
133
+ with pytest.raises(GraphError) as e:
134
+ write(g, inv, statement, [source(g, inv, UNITS)], scope="The parcel register.")
135
+ assert "SOURCE_CONTRADICTS" in codes(e)
136
+
137
+
138
+ @pytest.mark.parametrize("passage", [
139
+ "Shipping weights are recorded in kilograms; passenger weights are recorded in pounds.",
140
+ "Passenger weights are recorded in pounds; shipping weights are recorded in kilograms.",
141
+ "Shipping weights are recorded in kilograms."])
142
+ def test_another_thing_that_shares_a_word_is_not_contradicted(passage):
143
+ """HG-022-04: "passenger weights" was matched with "shipping weights" on the word "weights", and the true claim
144
+ was refused as going against its source. Two things each named for something the other isn't are two things."""
145
+ g, inv, _r = record({"x": {"k": 0, "n": 3}})
146
+ write(g, inv, "Passenger weights are recorded in pounds.", [source(g, inv, passage)], scope="The register.")
147
+ assert "a" not in g.conduct(inv)
148
+ # the same thing still is: "recorded shipping weights" against shipping weights in kilograms
149
+ with pytest.raises(GraphError) as e:
150
+ write(g, inv, "The recorded shipping weights are in pounds.",
151
+ [source(g, inv, "Shipping weights are recorded in kilograms.")], scope="The register.")
152
+ assert "SOURCE_CONTRADICTS" in codes(e)
153
+
154
+
155
+ @pytest.mark.parametrize("statement", ["The parcel mass is approximately two pounds.",
156
+ "The parcel mass is about 2.2 pounds.",
157
+ "The parcel mass is a little over two pounds."])
158
+ def test_a_quantity_in_another_unit_is_not_a_unit_of_record(statement):
159
+ """HG-022-05: "the parcel mass is about 2.2 pounds", citing "the parcel mass is one kilogram", was refused: a
160
+ quantity converted to another unit was read as a claim about which unit the records use. A unit after a number is
161
+ a quantity; conversions are not checked."""
162
+ g, inv, _r = record({"x": {"k": 0, "n": 3}})
163
+ try:
164
+ write(g, inv, statement, [source(g, inv, "The parcel mass is one kilogram.")],
165
+ scope="One parcel; 1 kg is about 2.2 pounds.")
166
+ except GraphError as e:
167
+ assert "SOURCE_CONTRADICTS" not in [p["code"] for p in e.problems], e.problems
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