hypgraph 0.7.26__tar.gz → 0.7.27__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 (126) hide show
  1. {hypgraph-0.7.26 → hypgraph-0.7.27}/CHANGELOG.md +23 -0
  2. {hypgraph-0.7.26 → hypgraph-0.7.27}/PKG-INFO +1 -1
  3. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/RELEASE-READINESS.md +15 -0
  5. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/__init__.py +1 -1
  6. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/checks.py +73 -25
  7. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/language.py +13 -5
  8. hypgraph-0.7.27/tests/test_borrowed_words.py +130 -0
  9. {hypgraph-0.7.26 → hypgraph-0.7.27}/.gitignore +0 -0
  10. {hypgraph-0.7.26 → hypgraph-0.7.27}/AGENTS.md +0 -0
  11. {hypgraph-0.7.26 → hypgraph-0.7.27}/CITATION.cff +0 -0
  12. {hypgraph-0.7.26 → hypgraph-0.7.27}/DISCLAIMER.md +0 -0
  13. {hypgraph-0.7.26 → hypgraph-0.7.27}/LICENSE +0 -0
  14. {hypgraph-0.7.26 → hypgraph-0.7.27}/NOTICE +0 -0
  15. {hypgraph-0.7.26 → hypgraph-0.7.27}/README.md +0 -0
  16. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  17. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  18. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/profile-workflows-plan.md +0 -0
  19. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/review-2026-10-24.md +0 -0
  20. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/site-plan.md +0 -0
  21. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/spec.md +0 -0
  22. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/ui-plan.md +0 -0
  23. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/verification.md +0 -0
  24. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/workflows/README.md +0 -0
  25. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/workflows/knowledge.md +0 -0
  26. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/workflows/optimization.md +0 -0
  27. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/workflows/scientific.md +0 -0
  28. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/workflows/software.md +0 -0
  29. {hypgraph-0.7.26 → hypgraph-0.7.27}/docs/workflows/trading.md +0 -0
  30. {hypgraph-0.7.26 → hypgraph-0.7.27}/examples/frameworks/README.md +0 -0
  31. {hypgraph-0.7.26 → hypgraph-0.7.27}/pyproject.toml +0 -0
  32. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/blind.py +0 -0
  34. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/bundle.py +0 -0
  35. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/demos/linked.py +0 -0
  40. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/graph.py +0 -0
  42. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/mcp.py +0 -0
  43. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/plan.py +0 -0
  44. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/policy.py +0 -0
  45. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/profiles.py +0 -0
  46. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/references.py +0 -0
  47. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/refusals.py +0 -0
  48. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/report_check.py +0 -0
  49. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/schema.py +0 -0
  50. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/server.py +0 -0
  51. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/skill/SKILL.md +0 -0
  52. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/stats.py +0 -0
  53. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/store.py +0 -0
  54. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/__init__.py +0 -0
  55. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/atlas.css +0 -0
  56. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/atlas.js +0 -0
  57. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/hypgraph.css +0 -0
  58. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
  59. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/hypgraph.js +0 -0
  60. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/hypgraph.svg +0 -0
  61. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/index.html +0 -0
  62. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  63. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  64. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  65. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  66. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  67. {hypgraph-0.7.26 → hypgraph-0.7.27}/src/hypgraph/workflow.py +0 -0
  68. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_accept_work_kind.py +0 -0
  69. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_anchoring.py +0 -0
  70. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_blind.py +0 -0
  71. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_blind_oracles.py +0 -0
  72. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_bound_support.py +0 -0
  73. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_bundle_telemetry.py +0 -0
  74. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_cancel_releases_lease.py +0 -0
  75. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_cited_and_failed_support.py +0 -0
  76. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_contradictions.py +0 -0
  77. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_coord.py +0 -0
  78. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_counts_that_bear.py +0 -0
  79. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_d10.py +0 -0
  80. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_direction_and_units.py +0 -0
  81. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_engine.py +0 -0
  82. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_engine_fixes.py +0 -0
  83. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_framework_examples.py +0 -0
  84. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_i18n.py +0 -0
  85. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_incremental.py +0 -0
  86. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_link_translate.py +0 -0
  87. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_linked_records.py +0 -0
  88. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_log_and_check.py +0 -0
  89. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_measure_questions.py +0 -0
  90. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_opposed_answers.py +0 -0
  91. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_option_lead.py +0 -0
  92. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_order_detail.py +0 -0
  93. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_profiles.py +0 -0
  94. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_profiles_041.py +0 -0
  95. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_quantifiers_and_identity.py +0 -0
  96. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_questions_not_assertions.py +0 -0
  97. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_references.py +0 -0
  98. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_refusal_friction.py +0 -0
  99. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_refusal_results.py +0 -0
  100. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_refusals.py +0 -0
  101. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_relation_words.py +0 -0
  102. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_report_answers.py +0 -0
  103. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_rerunnable.py +0 -0
  104. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_restates_scale.py +0 -0
  105. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_server_auth.py +0 -0
  106. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_server_hosts.py +0 -0
  107. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_shape_refusals.py +0 -0
  108. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_stats_not_backtests.py +0 -0
  109. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_stats_values.py +0 -0
  110. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_surfaces.py +0 -0
  111. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_telemetry_stream.py +0 -0
  112. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_trust_boundary.py +0 -0
  113. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_ui_app.py +0 -0
  114. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_ui_engine.py +0 -0
  115. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_uncorrected_effect.py +0 -0
  116. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_unnamed_inputs.py +0 -0
  117. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_verify_complete.py +0 -0
  118. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_verify_lifecycle.py +0 -0
  119. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_verify_scope.py +0 -0
  120. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_verify_scoped_views.py +0 -0
  121. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_verify_trust.py +0 -0
  122. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_verify_views.py +0 -0
  123. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_versions_not_figures.py +0 -0
  124. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_vocabulary_friction.py +0 -0
  125. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_what_a_count_is_of.py +0 -0
  126. {hypgraph-0.7.26 → hypgraph-0.7.27}/tests/test_workflows.py +0 -0
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.27 — 2026-11-01: words said elsewhere, comparisons the checks didn't know
4
+
5
+ Fixes from an independent audit of 0.7.26 (HG-026-01, -02); each case below failed on 0.7.26 and is now a test
6
+ (`tests/test_borrowed_words.py`).
7
+
8
+ - **A word said in another clause doesn't make a count fit** (HG-026-01). What a count's key says it counted had to be
9
+ said by the measurement's name or anywhere in the statement, so "Speed was recorded separately; no route is longer
10
+ than 26 km" let a speed count named "routes longer than 26 km" back the length claim, and "Cargo routes were
11
+ inspected; no passenger route is longer …" did the same for a cargo count. The words must now be said where the
12
+ claim states the count's figure (its clause, from the comma before its absolute or extreme). Eight paraphrases over
13
+ other subjects and properties, written after the fix, are tests too: an extra clause never turns a refusal into an
14
+ acceptance, and an honest count backs the claim with or without one.
15
+ - **Any comparison makes an absolute** (HG-026-01, found while reproducing it). "No crate is heavier than 40 kg" and "no
16
+ shift had errors above 5" weren't read as absolutes, so they went in with no count, or with a count of crate prices.
17
+ "No …" before any comparison word is now an absolute; heavier / lighter, warmer / colder, older / younger, taller,
18
+ wider, deeper, louder and stronger are comparisons along their own axes (weight, temperature, age, height, width,
19
+ depth, loudness, strength), and a comparative the checks don't know ("brighter than") is met only by a count that
20
+ says the same word. A three-letter word before a comparison ("bridge age above 60") now names what was counted, and
21
+ "span" is a length, so an honest count of bridge spans backs "no bridge is longer than 60 m".
22
+ - **A mixed "and" / "or" clause has to be split** (HG-026-02). 0.7.26's notes said such a clause is unresolved "until a
23
+ count of the whole condition is bound"; nothing ties a count to the whole condition, so that never happened. The
24
+ refusal now says to split the claim into clauses that each join their comparisons one way.
25
+
3
26
  ## 0.7.26 — 2026-11-01: what a count is of, what its key compared, "and" with "or"
4
27
 
5
28
  Fixes from an independent audit of 0.7.25 (HG-025-01 to -03); each case below failed on 0.7.25 and is now a test
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.26
3
+ Version: 0.7.27
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.26 (2026-11-01)
112
+ ## Status at 0.7.27 (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
@@ -181,6 +181,21 @@ was taken from a changelog without being checked against the code.*
181
181
  > **Where that stands.** #96 is merged with green CI (17 of 17 checks). Kinds and subjects are still read from the
182
182
  > words around a comparison, a heuristic; the typed claim layer the audits recommend is not built. Released as 0.7.26.
183
183
 
184
+ > **Audit of 0.7.26 (2026-11-01), fixed for 0.7.27.** A seventh independent audit named two defects; both reproduced on
185
+ > the PyPI 0.7.26 wheel. A clause that only mentioned speed ("Speed was recorded separately; no route is longer than
186
+ > 26 km") let a speed count named "routes longer than 26 km" back the length claim, and the same for cargo named as
187
+ > passenger routes: the key's words were looked for anywhere in the statement (HG-026-01). Reproducing it found a
188
+ > wider gap: "no crate is heavier than 40 kg" wasn't read as an absolute, so it went in with no count or with a count
189
+ > of crate prices. And the 0.7.26 note that a count of the whole condition settles a mixed "and" / "or" clause promised
190
+ > something nothing implements (HG-026-02). A key's words must now be said where the claim states the count's figure;
191
+ > "no …" before any comparison is an absolute; eight more comparison pairs have axes of their own, and an unknown
192
+ > "-er than" is met only by a count saying the same word; the mixed-clause refusal says to split the claim. Eleven
193
+ > paraphrases written after the first fix found three more gaps (a three-letter subject, "span", a comma-led aside),
194
+ > fixed before merge. Replaying the 1,900 recorded adversarial entries changes none.
195
+ >
196
+ > **Where that stands.** #98 is merged with green CI (17 of 17 checks). This is still word matching; the typed claim
197
+ > layer the audits recommend is not built. Released as 0.7.27.
198
+
184
199
  ## Recommendation: CONDITIONAL GO
185
200
 
186
201
  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.26"
19
+ __version__ = "0.7.27"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -243,7 +243,7 @@ def _bound(s: Snap, meas: list, stmt: str = "") -> tuple[list[tuple[dict, dict]]
243
243
  bad = True
244
244
  # a name that says no comparison ("fitting selections") doesn't say another count; one that does must keep
245
245
  # what the key counted, here or in the statement
246
- unsaid = _unsaid(_subject_of(key), f"{m.get('name') or ''} . {stmt}")
246
+ unsaid = _unsaid(_subject_of(key), f"{m.get('name') or ''} . {_clauses_at(stmt, _count_figures(m, rv))}")
247
247
  if not bad and unsaid and _comparisons_in(str(m.get("name") or "")):
248
248
  problems.append(Problem("MEASUREMENT_MISMATCH", f"measurement {m.get('name')}: result {m['result']} "
249
249
  f"counted '{m['key']}', and neither the name nor the statement says what it "
@@ -256,6 +256,32 @@ def _bound(s: Snap, meas: list, stmt: str = "") -> tuple[list[tuple[dict, dict]]
256
256
  return good, problems
257
257
 
258
258
 
259
+ # where one thing said ends and another begins: a sentence, a semicolon or colon, or a comma before a conjunction
260
+ _SAYING = re.compile(r"[;:!?]|\.(?!\d)|,\s*(?=(?:and|but|while|whereas|although|though|so|yet)\b)|"
261
+ r"\b(?:although|though|whereas)\b", re.I)
262
+
263
+
264
+ def _clauses_at(stmt: str, figs: set[float]) -> str:
265
+ """The parts of ``stmt`` that state one of ``figs``: what a count against that figure must be about is said there,
266
+ not in another sentence. "Speed was recorded separately; no route is longer than 26 km" says nothing about speed
267
+ where it says 26 km, so a speed count named "routes longer than 26 km" can't borrow the word (audit HG-026-01).
268
+ The whole statement when no part states one."""
269
+ if not stmt or not figs:
270
+ return stmt
271
+ parts = [p for p in _SAYING.split(stmt) if p and p.strip()]
272
+ hit = [_from_claim(p) for p in parts if {round(v[0], 6) for v in numbers(p)} & {round(f, 6) for f in figs}]
273
+ return " . ".join(hit) if hit else stmt
274
+
275
+
276
+ def _from_claim(part: str) -> str:
277
+ """A part from the comma before its absolute or extreme: "Rural sites aside, no urban clinic had waits above 45"
278
+ claims only "no urban clinic had waits above 45"."""
279
+ hit = L.ABSOLUTE.search(part) or L.SUPERLATIVE.search(part)
280
+ if not hit:
281
+ return part
282
+ return part[max((x.end() for x in re.finditer(r",(?!\d)", part[:hit.start()])), default=0):]
283
+
284
+
259
285
  def _axis_words(axes: set[str]) -> str:
260
286
  return " / ".join(sorted("/".join(w for w, (a, _s) in _DIRECTION_OF.items() if a == x and not w.endswith("est"))
261
287
  for x in axes))
@@ -309,13 +335,11 @@ _PROVEN = re.compile(r"^prov(?:es?|en|ed)$|^proof$", re.I)
309
335
 
310
336
  # the figure a count is about: "fitting selections worth more than 93", "routes shorter than 26 km" (or, matching a
311
337
  # figure the claim names, "worth_more_93"). A count of nothing beyond 140 says nothing about 93 (audit HG-020-01/02).
312
- _COUNT_FIGURE = re.compile(r"\b(?:(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
313
- r"slower|larger|smaller)\s+than|above|below|over|under|beyond|exceed\w*|beat\w*)\s+\$?"
338
+ _COUNT_FIGURE = re.compile(r"\b(?:(?:" + L.CMP_WORDS + r"|(?!(?:" + L.NOT_CMP + r")\b)[a-z]{3,}er)\s+than|above|below|over|under|beyond|exceed\w*|beat\w*)\s+\$?"
314
339
  r"(-?\d[\d,]*(?:\.\d+)?)", re.I)
315
340
  # the figure a name gives before its comparison or with "at least": "routes 26 km or longer", "routes at least 26 km"
316
341
  _COUNT_FIGURE_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
317
- r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:more|less|fewer|greater|higher|lower|better|worse|"
318
- r"shorter|longer|cheaper|faster|slower|larger|smaller)\b", re.I)
342
+ r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + L.CMP_WORDS + r")\b", re.I)
319
343
 
320
344
 
321
345
  def _named_figure(name: str) -> tuple[float, str] | None:
@@ -329,16 +353,13 @@ def _named_figure(name: str) -> tuple[float, str] | None:
329
353
  return None
330
354
 
331
355
 
332
- _KEY_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
333
- r"slower|larger|smaller|above|below|over|under|beyond|exceed\w*|beat\w*)(?:\s+than)?\s+"
356
+ _KEY_FIGURE = re.compile(r"\b(?:" + L.CMP_WORDS + r"|above|below|over|under|beyond|exceed\w*|beat\w*)(?:\s+than)?\s+"
334
357
  r"(\d+(?:\.\d+)?)$", re.I)
335
358
  # the figure an absolute names in its own clause: "nothing worth more than 93 fits", "no route is shorter than 26 km"
336
- _CLAIM_FIGURE = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|"
337
- r"slower|larger|smaller)\s+than\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\b(?:above|below|beyond|"
359
+ _CLAIM_FIGURE = re.compile(r"\b(?:" + L.CMP_WORDS + r"|(?!(?:" + L.NOT_CMP + r")\b)[a-z]{3,}er)\s+than\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\b(?:above|below|beyond|"
338
360
  r"exceed(?:s|ing)?)\s+\$?(-?\d[\d,]*(?:\.\d+)?)", re.I)
339
361
  _CLAIM_BOUNDARY = re.compile(r"\bat\s+(?:least|most)\s+\$?(-?\d[\d,]*(?:\.\d+)?)|\$?(-?\d[\d,]*(?:\.\d+)?)"
340
- r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:more|less|fewer|greater|higher|lower|better|worse|"
341
- r"shorter|longer|cheaper|faster|slower|larger|smaller)\b", re.I)
362
+ r"(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + L.CMP_WORDS + r")\b", re.I)
342
363
  _SUBCLAUSE = re.compile(r",(?!\d)|[;:!?()]|\.(?!\d)|\b(?:and|but|while|whereas|although|so)\b", re.I)
343
364
 
344
365
 
@@ -382,6 +403,14 @@ _DIRECTION_WORDS = {
382
403
  "fast": {1: "faster fastest", -1: "slower slowest"},
383
404
  "cost": {1: "dearer dearest", -1: "cheaper cheapest"},
384
405
  "time": {1: "later latest", -1: "earlier earliest"},
406
+ "heavy": {1: "heavier heaviest", -1: "lighter lightest"},
407
+ "warm": {1: "warmer warmest hotter hottest", -1: "colder coldest cooler coolest"},
408
+ "old": {1: "older oldest", -1: "younger youngest newer newest"},
409
+ "tall": {1: "taller tallest"},
410
+ "wide": {1: "wider widest", -1: "narrower narrowest"},
411
+ "deep": {1: "deeper deepest", -1: "shallower shallowest"},
412
+ "loud": {1: "louder loudest", -1: "quieter quietest"},
413
+ "strong": {1: "stronger strongest", -1: "weaker weakest"},
385
414
  }
386
415
  _DIRECTION_OF = {w: (axis, sign) for axis, signs in _DIRECTION_WORDS.items() for sign, ws in signs.items()
387
416
  for w in ws.split()}
@@ -389,8 +418,7 @@ _DIRECTION_OF = {w: (axis, sign) for axis, signs in _DIRECTION_WORDS.items() for
389
418
  # not strict, so a route of exactly 26 lies inside them and outside "longer than" (audit HG-023-02). Six operators;
390
419
  # "=" and "!=" point nowhere.
391
420
  _OPS = {">": (1, True), ">=": (1, False), "<": (-1, True), "<=": (-1, False), "=": None, "!=": None}
392
- _CMP = r"(more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|dearer|faster|slower|larger|" \
393
- r"smaller|bigger|earlier|later)"
421
+ _CMP = r"(" + L.CMP_WORDS + r")"
394
422
  _NOT_STRICT = re.compile(r"\bat\s+(least|most)\b|\b(?:not|no)\s+" + _CMP + r"\s+than\b|\bor\s+" + _CMP + r"\b", re.I)
395
423
 
396
424
 
@@ -408,7 +436,13 @@ def _comparisons_in(text: str) -> set[tuple[str, int, bool]]:
408
436
  else:
409
437
  out.add((*_DIRECTION_OF[h.group(3)], False))
410
438
  rest = _NOT_STRICT.sub(" ", text)
411
- return out | {(*_DIRECTION_OF[w], True) for w in re.findall(r"[a-z]+", rest) if w in _DIRECTION_OF}
439
+ # a comparative the checks don't know ("brighter than") is an axis of its own: only a count that says the same word
440
+ # meets it, so nothing else can back a claim made with it (audit HG-026-01)
441
+ unknown = {(w, 1, True) for w in _UNKNOWN_CMP.findall(rest) if w not in _DIRECTION_OF}
442
+ return out | unknown | {(*_DIRECTION_OF[w], True) for w in re.findall(r"[a-z]+", rest) if w in _DIRECTION_OF}
443
+
444
+
445
+ _UNKNOWN_CMP = re.compile(r"\b(?!(?:" + L.NOT_CMP + r")\b)([a-z]{3,}er)\s+than\b")
412
446
 
413
447
 
414
448
  def _directions(text: str) -> set[tuple[str, int]]:
@@ -582,12 +616,23 @@ _COUNTING = set("count counts counted counting number numbers total totals tally
582
616
  _HOW_MANY = set("nothing none never nobody every always ever each whole entire".split())
583
617
 
584
618
 
619
+ # three-letter words that say nothing about what was counted
620
+ _SHORT_SKIP = set("has had did got get let may can one two six ten few new old top max min own via nor yet per".split())
621
+
622
+
623
+ def _stems(text: str) -> set[str]:
624
+ """``L.content_words`` and the three-letter words it leaves out, which can name what was counted: a count of
625
+ "bridge age above 60" is of ages, and "age" must be said for it to back a claim (audit HG-026-01)."""
626
+ return L.content_words(text) | {w for w in re.findall(r"[a-z]+", (text or "").lower())
627
+ if len(w) == 3 and w not in L.STOP and w not in _SHORT_SKIP}
628
+
629
+
585
630
  def _modifiers(words: list[str], i: int) -> set[str]:
586
631
  """The content words right before ``words[i]``, up to a determiner, number, preposition or comparison: "no passenger
587
632
  route" gives {passeng}, "the 3 cargo routes" {cargo}, "the shortest route" nothing."""
588
633
  out: set[str] = set()
589
634
  for w in reversed(words[:i]):
590
- cw = L.content_words(w)
635
+ cw = _stems(w)
591
636
  if not cw or w in _DETERMINERS or w in _PREPOSITIONS or w in _DIRECTION_OF or w in _COUNTING | _HOW_MANY:
592
637
  break
593
638
  out |= cw
@@ -616,7 +661,7 @@ def _subject_parts(text: str) -> tuple[set[str], set[str]]:
616
661
  while i > 1 and words[i - 1] in _MEASURED_BY: # "selections worth more": the head is selections
617
662
  whole -= L.content_words(words[i - 1])
618
663
  i -= 1
619
- head = L.content_words(words[i - 1]) & whole
664
+ head = _stems(words[i - 1]) & whole
620
665
  return head, whole - head
621
666
 
622
667
 
@@ -626,15 +671,18 @@ _MEASURED_BY = set("worth valued priced weighing costing lasting taking".split()
626
671
 
627
672
  _COPULAS = set("is are was were be been being".split())
628
673
  # the property a comparison word compares: a count of "route length above 26" is said by "no route is longer than 26"
629
- _PROPERTY_AXIS = {s: axis for axis, ws in {"long": "length distance", "fast": "speed velocity pace",
630
- "cost": "cost price", "time": "date time"}.items()
631
- for w in ws.split() for s in L.content_words(w)}
674
+ _PROPERTY_AXIS = {s: axis for axis, ws in {"long": "length distance span", "fast": "speed velocity pace",
675
+ "cost": "cost price", "time": "date time", "heavy": "weight mass",
676
+ "warm": "temperature heat", "old": "age", "tall": "height",
677
+ "wide": "width breadth", "deep": "depth", "loud": "loudness",
678
+ "strong": "strength"}.items()
679
+ for w in ws.split() for s in _stems(w)}
632
680
 
633
681
 
634
682
  def _unsaid(stems: set[str], text: str) -> set[str]:
635
683
  """``stems`` that ``text`` doesn't say: neither as words nor, for a property, by comparing along its axis."""
636
684
  axes = {c[0] for c in _comparisons_in(text) if c[0]}
637
- return {s for s in stems - L.content_words(text) if _PROPERTY_AXIS.get(s) not in axes}
685
+ return {s for s in stems - _stems(text) if _PROPERTY_AXIS.get(s) not in axes}
638
686
 
639
687
 
640
688
  def _subjects_at(stmt: str, axes: set[str]) -> set[str]:
@@ -733,9 +781,10 @@ def _absolutes(stmt: str, scope: str, body: dict, bound: list) -> list[Problem]:
733
781
  seen.add((tuple(group), _ALL.match(w) is None))
734
782
  if group[0] is MIXED:
735
783
  p = Problem("ABSOLUTE_WITHOUT_COUNT", f"'{w}' rules on comparisons joined by both 'and' and 'or': "
736
- "counts of each comparison on its own can't settle that either way. Count what meets the "
737
- "whole condition, or split it into claims that each join their comparisons one way",
738
- fix="count what meets the whole condition, or split the claim", words=words)
784
+ "counts of each comparison on its own can't settle that either way, and no count can be "
785
+ "tied to the whole condition. Split it into claims that each join their comparisons one "
786
+ "way (audit HG-026-02)",
787
+ fix="split the claim into clauses that each join their comparisons one way", words=words)
739
788
  if p.text() not in {q.text() for q in out}:
740
789
  out.append(p)
741
790
  continue
@@ -860,8 +909,7 @@ def _denied(after: str) -> bool:
860
909
 
861
910
  # a count named for what lies beyond a figure ("fitting selections worth more", "shorter routes connecting") is about
862
911
  # any extreme the statement claims, whatever words the statement uses for it ("the highest total value is 99")
863
- _BEYOND = re.compile(r"\b(?:more|less|fewer|greater|higher|lower|better|worse|shorter|longer|cheaper|faster|slower|"
864
- r"larger|smaller|above|below|exceed\w*|beat\w*)\b", re.I)
912
+ _BEYOND = re.compile(r"\b(?:" + L.CMP_WORDS + r"|above|below|exceed\w*|beat\w*)\b", re.I)
865
913
 
866
914
 
867
915
  def _extremes(stmt: str) -> list[str]:
@@ -14,6 +14,12 @@ from __future__ import annotations
14
14
 
15
15
  import re
16
16
 
17
+ # every comparative the checks know, and the "-er than" words that aren't one ("other than", "rather than")
18
+ CMP_WORDS = ("longer|shorter|more|less|fewer|greater|higher|lower|larger|smaller|bigger|better|worse|faster|slower|"
19
+ "cheaper|dearer|earlier|later|heavier|lighter|warmer|hotter|colder|cooler|older|younger|newer|taller|wider|"
20
+ "narrower|deeper|shallower|louder|quieter|stronger|weaker")
21
+ NOT_CMP = "other|rather|either|neither|whether|never|ever|over|under|after|per|number|order|matter"
22
+
17
23
  ABSOLUTE = re.compile(
18
24
  r"\b(never|always|all|every|none|nothing|no one|nobody|impossible|cannot|can't|proves?|proven|proof|certainly|"
19
25
  r"certain|guaranteed?|guarantees|eliminat(?:es|ed|ing)|entirely|completely|totally|universally|invariably|"
@@ -22,14 +28,16 @@ ABSOLUTE = re.compile(
22
28
  # "no route is longer than 26 km": a universal negative comparison, the same claim as "nothing is longer"; only
23
29
  # where "no" opens its clause ("routes that visit no town twice" is not one); "no route is 26 km or longer" and
24
30
  # "no route is at least 26 km" too
25
- r"|(?<![a-z] )no(?=\s+(?:[a-z]+\s+){1,3}?(?:is|are|was|were)\s+(?:any\s+)?(?:at\s+(?:least|most)\s|"
26
- r"\$?\d[\d,.]*(?:\s+[a-z%]+){0,2}\s+or\s+(?:longer|shorter|more|less|fewer|greater|higher|lower|larger|smaller|"
27
- r"better|worse|faster|slower|cheaper)\b|(?:longer|shorter|more|less|fewer|greater|higher|lower|larger|smaller|"
28
- r"better|worse|faster|slower|cheaper|above|below|over|under)\b)))\b|(?<![\d.])100\s*%", re.I)
31
+ # Any comparison word, and any verb before it: "no crate is heavier than 40 kg", "no shift had errors above 5",
32
+ # "no crate weighs more than 40 kg" (audit HG-026-01: these went unread, so any count backed them)
33
+ r"|(?<![a-z] )no(?=\s+(?:[a-z]+\s+){1,4}?(?:(?:is|are|was|were)\s+(?:any\s+)?)?(?:at\s+(?:least|most)\s|"
34
+ r"\$?\d[\d,.]*(?:\s+[a-z%]+){0,2}\s+or\s+(?:" + CMP_WORDS + r")\b|(?:" + CMP_WORDS + r"|above|below|over|under|"
35
+ r"exceed(?:s|ed|ing)?)\b|(?!(?:" + NOT_CMP + r")\b)[a-z]{3,}er\s+than\b)))\b|(?<![\d.])100\s*%", re.I)
29
36
  # a superlative names an extreme ("the highest value is 93"): a count of anything beyond it contradicts it
30
37
  SUPERLATIVE = re.compile(
31
38
  r"\b(highest|lowest|largest|smallest|shortest|longest|greatest|fewest|best|worst|maximum|minimum|optimal|"
32
- r"optimum|cheapest|fastest|slowest|biggest|earliest|latest)\b", re.I)
39
+ r"optimum|cheapest|fastest|slowest|biggest|earliest|latest|heaviest|lightest|warmest|hottest|coldest|coolest|"
40
+ r"oldest|youngest|newest|tallest|widest|narrowest|deepest|shallowest|loudest|quietest|strongest|weakest)\b", re.I)
33
41
  COMPARATIVE = re.compile(
34
42
  r"\b(better|worse|beats?|beat|outperform(?:s|ed|ing)?|underperform(?:s|ed|ing)?|improv(?:es|ed|ement|ing)|"
35
43
  r"superior|inferior|exceeds?|exceeded|higher than|lower than|more than|fewer than|less than|faster|slower|"
@@ -0,0 +1,130 @@
1
+ """A word said somewhere else in the statement, and a comparison the checks didn't know (audit of 0.7.26, HG-026-01
2
+ and -02).
3
+
4
+ A count's key says what was counted; the claimant's name for it must keep that, here or where the claim states the
5
+ count's figure. "Speed was recorded separately; no route is longer than 26 km" says nothing about speed where it says
6
+ 26 km, so a speed count named "routes longer than 26 km" can't borrow the word. The invariant: an extra clause never
7
+ turns a refusal into an acceptance, and an honest count backs the claim with or without it. "No crate is heavier than
8
+ 40 kg" is an absolute like "no route is longer than 26 km", and a comparison the checks don't know ("brighter than")
9
+ is met only by a count that says the same word."""
10
+ import pytest
11
+
12
+ from hypgraph import Graph, GraphError, Policy
13
+
14
+ SCOPE = "The surveyed items, against the stated mark."
15
+
16
+
17
+ def outcome(values, statement, meas=()):
18
+ g = Graph(policy=Policy(reviewers={"critic"}))
19
+ inv = g.author("curator").investigation("Survey", "What do the surveyed items show?")["id"]
20
+ lab = g.author("lab")
21
+ run = lab.add("run", inv, kind="lab", state="succeeded", name="Measured the surveyed items.")
22
+ r = lab.add("result", inv, kind="computed", outcome="positive", summary="Counts of the surveyed items.",
23
+ values=values, links=[{"rel": "produced", "from": run["id"]}])["id"]
24
+ try:
25
+ g.author("a").add("claim", inv, statement=statement, scope=SCOPE,
26
+ measurements=[{**m, "result": r} for m in meas],
27
+ links=[{"rel": "supports", "from": r, "why": "Cited for the claim."}])
28
+ return []
29
+ except GraphError as e:
30
+ return [p["code"] for p in e.problems]
31
+
32
+
33
+ def t(k, figure, op=">", n=3):
34
+ return {"k": k, "n": n, "figure": figure, "op": op}
35
+
36
+
37
+ def M(key, name, k, n=3):
38
+ return {"name": name, "key": key, "k": k, "n": n}
39
+
40
+
41
+ # (frozen values, the relabelled count, the claim, the claim with the borrowed word said elsewhere)
42
+ LAUNDER = {
43
+ "speed as length": ({"route_speed_above_26": t(0, 26)}, M("route_speed_above_26", "routes longer than 26 km", 0),
44
+ "No route is longer than 26 km among the 3 routes.",
45
+ ["Speed was recorded separately; no route is longer than 26 km among the 3 routes."]),
46
+ "cargo as passenger": ({"cargo_route_longer_26": t(0, 26), "passenger_route_longer_26": t(1, 26)},
47
+ M("cargo_route_longer_26", "passenger route longer than 26 km", 0),
48
+ "No passenger route is longer than 26 km among the 3 routes.",
49
+ ["Cargo routes were inspected; no passenger route is longer than 26 km among the 3 "
50
+ "routes."]),
51
+ "night as day": ({"night_shift_errors_above_5": t(0, 5), "day_shift_errors_above_5": t(3, 5)},
52
+ M("night_shift_errors_above_5", "day shift errors above 5", 0),
53
+ "No day shift had errors above 5 among the 3 shifts.",
54
+ ["The night shift was audited separately; no day shift had errors above 5 among the 3 shifts."]),
55
+ "price as weight": ({"crate_price_above_40": t(0, 40)}, M("crate_price_above_40", "crates heavier than 40 kg", 0),
56
+ "No crate is heavier than 40 kg among the 3 crates.",
57
+ ["Prices were logged too; no crate is heavier than 40 kg among the 3 crates."]),
58
+ "frozen as fresh": ({"frozen_sample_warmer_8": t(0, 8), "fresh_sample_warmer_8": t(2, 8)},
59
+ M("frozen_sample_warmer_8", "fresh sample warmer than 8 C", 0),
60
+ "No fresh sample is warmer than 8 C among the 3 samples.",
61
+ ["Frozen samples were checked as well; no fresh sample is warmer than 8 C among the 3 "
62
+ "samples."]),
63
+ "age as span": ({"bridge_age_above_60": t(0, 60)}, M("bridge_age_above_60", "bridges longer than 60 m", 0),
64
+ "No bridge is longer than 60 m among the 3 bridges.",
65
+ ["Ages were taken from the register. No bridge is longer than 60 m among the 3 bridges.",
66
+ "Although age was recorded, no bridge is longer than 60 m among the 3 bridges.",
67
+ "We noted each bridge's age, and no bridge is longer than 60 m among the 3 bridges."]),
68
+ "rural as urban": ({"rural_clinic_waits_above_45": t(0, 45), "urban_clinic_waits_above_45": t(3, 45)},
69
+ M("rural_clinic_waits_above_45", "urban clinic waits above 45 minutes", 0),
70
+ "No urban clinic had waits above 45 minutes among the 3 clinics.",
71
+ ["Rural clinics were visited first; no urban clinic had waits above 45 minutes among the 3 "
72
+ "clinics.",
73
+ "Rural sites aside, no urban clinic had waits above 45 minutes among the 3 clinics."]),
74
+ "unknown comparison": ({"lamp_wattage_above_40": t(0, 40)}, M("lamp_wattage_above_40", "lamps brighter than 40 lux",
75
+ 0),
76
+ "No lamp is brighter than 40 lux among the 3 lamps.",
77
+ ["Wattage was read off each label. No lamp is brighter than 40 lux among the 3 lamps."]),
78
+ }
79
+
80
+
81
+ @pytest.mark.parametrize("case", list(LAUNDER))
82
+ def test_a_word_said_elsewhere_does_not_launder_a_count(case):
83
+ """HG-026-01: the missing word was looked for anywhere in the statement, so a clause that only mentioned speed (or
84
+ cargo, or the night shift) let a count of it back a claim about something else."""
85
+ values, m, claim, padded = LAUNDER[case]
86
+ assert "MEASUREMENT_MISMATCH" in outcome(values, claim, [m])
87
+ for s in padded:
88
+ assert "MEASUREMENT_MISMATCH" in outcome(values, s, [m]), s
89
+
90
+
91
+ HONEST = [
92
+ ({"passenger_route_longer_26": t(0, 26)}, M("passenger_route_longer_26", "passenger routes longer than 26 km", 0),
93
+ ["No passenger route is longer than 26 km among the 3 routes.",
94
+ "Cargo routes were inspected; no passenger route is longer than 26 km among the 3 routes."]),
95
+ ({"bridge_span_above_60": t(0, 60)}, M("bridge_span_above_60", "bridge spans above 60 m", 0),
96
+ ["No bridge is longer than 60 m among the 3 bridges.",
97
+ "Ages were taken from the register. No bridge is longer than 60 m among the 3 bridges."]),
98
+ ({"well_temperature_above_30": t(0, 30)}, M("well_temperature_above_30", "well temperature above 30 C", 0),
99
+ ["No well is warmer than 30 C among the 3 wells.",
100
+ "Depth was logged: no well is warmer than 30 C among the 3 wells."]),
101
+ ({"crate_weight_above_40": t(0, 40)}, M("crate_weight_above_40", "crates heavier than 40 kg", 0),
102
+ ["No crate is heavier than 40 kg among the 3 crates.",
103
+ "Prices were logged too; no crate is heavier than 40 kg among the 3 crates."]),
104
+ ({"lamps_brighter_than_40": t(0, 40)}, M("lamps_brighter_than_40", "lamps brighter than 40 lux", 0),
105
+ ["No lamp is brighter than 40 lux among the 3 lamps."]),
106
+ ]
107
+
108
+
109
+ @pytest.mark.parametrize("i", range(len(HONEST)))
110
+ def test_an_honest_count_backs_the_claim_with_or_without_another_clause(i):
111
+ values, m, statements = HONEST[i]
112
+ for s in statements:
113
+ assert outcome(values, s, [m]) == [], s
114
+
115
+
116
+ @pytest.mark.parametrize("s", ["No crate is heavier than 40 kg among the 3 crates.",
117
+ "No lamp is brighter than 40 lux among the 3 lamps.",
118
+ "No shift had errors above 5 among the 3 shifts."])
119
+ def test_an_absolute_with_any_comparison_needs_a_count(s):
120
+ """These weren't read as absolutes, so the record took them with no count at all, or with a count of anything."""
121
+ assert "ABSOLUTE_WITHOUT_COUNT" in outcome({"other": t(0, 1)}, s)
122
+
123
+
124
+ def test_a_count_cannot_be_tied_to_a_mixed_condition():
125
+ """HG-026-02: 0.7.26's notes said a count of the whole condition would settle a mixed "and" / "or" clause; nothing
126
+ ties a count to it, so the refusal says to split the claim."""
127
+ values = {"longer_10_and_shorter_30_or_shorter_6": t(3, 10), "longer_10": t(2, 10)}
128
+ codes = outcome(values, "Every route is longer than 10 km and shorter than 30 km or shorter than 6 km among the 3 "
129
+ "routes.", [M("longer_10", "routes longer than 10 km", 2)])
130
+ assert "ABSOLUTE_WITHOUT_COUNT" in codes
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