hypgraph 0.7.28__tar.gz → 0.8.0__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 (130) hide show
  1. {hypgraph-0.7.28 → hypgraph-0.8.0}/CHANGELOG.md +38 -0
  2. {hypgraph-0.7.28 → hypgraph-0.8.0}/PKG-INFO +1 -1
  3. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/PLAN.md +1 -1
  4. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/RELEASE-READINESS.md +21 -0
  5. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/spec.md +52 -3
  6. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/__init__.py +1 -1
  7. hypgraph-0.8.0/src/hypgraph/assertions.py +384 -0
  8. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/bundle.py +15 -3
  9. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/checks.py +142 -9
  10. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/derive.py +22 -1
  11. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/graph.py +11 -2
  12. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/mcp.py +7 -2
  13. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/plan.py +1 -1
  14. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/profiles.py +1 -0
  15. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/refusals.py +20 -13
  16. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/schema.py +2 -0
  17. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/skill/SKILL.md +11 -0
  18. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/stats.py +8 -0
  19. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/hypgraph.i18n.js +12 -0
  20. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/hypgraph.js +12 -2
  21. hypgraph-0.8.0/tests/test_assertions.py +390 -0
  22. hypgraph-0.8.0/tests/test_typed_audits.py +166 -0
  23. {hypgraph-0.7.28 → hypgraph-0.8.0}/.gitignore +0 -0
  24. {hypgraph-0.7.28 → hypgraph-0.8.0}/AGENTS.md +0 -0
  25. {hypgraph-0.7.28 → hypgraph-0.8.0}/CITATION.cff +0 -0
  26. {hypgraph-0.7.28 → hypgraph-0.8.0}/DISCLAIMER.md +0 -0
  27. {hypgraph-0.7.28 → hypgraph-0.8.0}/LICENSE +0 -0
  28. {hypgraph-0.7.28 → hypgraph-0.8.0}/NOTICE +0 -0
  29. {hypgraph-0.7.28 → hypgraph-0.8.0}/README.md +0 -0
  30. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  31. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  32. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/profile-workflows-plan.md +0 -0
  33. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/review-2026-10-24.md +0 -0
  34. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/site-plan.md +0 -0
  35. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/ui-plan.md +0 -0
  36. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/verification.md +0 -0
  37. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/workflows/README.md +0 -0
  38. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/workflows/knowledge.md +0 -0
  39. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/workflows/optimization.md +0 -0
  40. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/workflows/scientific.md +0 -0
  41. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/workflows/software.md +0 -0
  42. {hypgraph-0.7.28 → hypgraph-0.8.0}/docs/workflows/trading.md +0 -0
  43. {hypgraph-0.7.28 → hypgraph-0.8.0}/examples/frameworks/README.md +0 -0
  44. {hypgraph-0.7.28 → hypgraph-0.8.0}/pyproject.toml +0 -0
  45. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/__main__.py +0 -0
  46. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/blind.py +0 -0
  47. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/cli.py +0 -0
  48. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/coord.py +0 -0
  49. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/demos/__init__.py +0 -0
  50. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/demos/broad_street.py +0 -0
  51. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/demos/linked.py +0 -0
  52. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/language.py +0 -0
  53. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/policy.py +0 -0
  54. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/references.py +0 -0
  55. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/report_check.py +0 -0
  56. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/server.py +0 -0
  57. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/store.py +0 -0
  58. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/__init__.py +0 -0
  59. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/atlas.css +0 -0
  60. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/atlas.js +0 -0
  61. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/hypgraph.css +0 -0
  62. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/hypgraph.svg +0 -0
  63. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/index.html +0 -0
  64. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  65. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  66. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  67. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  68. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  69. {hypgraph-0.7.28 → hypgraph-0.8.0}/src/hypgraph/workflow.py +0 -0
  70. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_accept_work_kind.py +0 -0
  71. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_anchoring.py +0 -0
  72. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_blind.py +0 -0
  73. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_blind_oracles.py +0 -0
  74. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_borrowed_words.py +0 -0
  75. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_bound_support.py +0 -0
  76. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_bundle_telemetry.py +0 -0
  77. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_cancel_releases_lease.py +0 -0
  78. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_cited_and_failed_support.py +0 -0
  79. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_contradictions.py +0 -0
  80. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_coord.py +0 -0
  81. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_counts_that_bear.py +0 -0
  82. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_d10.py +0 -0
  83. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_direction_and_units.py +0 -0
  84. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_engine.py +0 -0
  85. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_engine_fixes.py +0 -0
  86. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_figure_said_twice.py +0 -0
  87. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_framework_examples.py +0 -0
  88. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_i18n.py +0 -0
  89. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_incremental.py +0 -0
  90. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_link_translate.py +0 -0
  91. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_linked_records.py +0 -0
  92. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_log_and_check.py +0 -0
  93. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_measure_questions.py +0 -0
  94. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_opposed_answers.py +0 -0
  95. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_option_lead.py +0 -0
  96. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_order_detail.py +0 -0
  97. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_profiles.py +0 -0
  98. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_profiles_041.py +0 -0
  99. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_quantifiers_and_identity.py +0 -0
  100. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_questions_not_assertions.py +0 -0
  101. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_references.py +0 -0
  102. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_refusal_friction.py +0 -0
  103. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_refusal_results.py +0 -0
  104. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_refusals.py +0 -0
  105. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_relation_words.py +0 -0
  106. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_report_answers.py +0 -0
  107. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_rerunnable.py +0 -0
  108. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_restates_scale.py +0 -0
  109. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_server_auth.py +0 -0
  110. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_server_hosts.py +0 -0
  111. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_shape_refusals.py +0 -0
  112. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_stats_not_backtests.py +0 -0
  113. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_stats_values.py +0 -0
  114. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_surfaces.py +0 -0
  115. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_telemetry_stream.py +0 -0
  116. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_trust_boundary.py +0 -0
  117. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_ui_app.py +0 -0
  118. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_ui_engine.py +0 -0
  119. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_uncorrected_effect.py +0 -0
  120. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_unnamed_inputs.py +0 -0
  121. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_verify_complete.py +0 -0
  122. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_verify_lifecycle.py +0 -0
  123. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_verify_scope.py +0 -0
  124. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_verify_scoped_views.py +0 -0
  125. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_verify_trust.py +0 -0
  126. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_verify_views.py +0 -0
  127. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_versions_not_figures.py +0 -0
  128. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_vocabulary_friction.py +0 -0
  129. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_what_a_count_is_of.py +0 -0
  130. {hypgraph-0.7.28 → hypgraph-0.8.0}/tests/test_workflows.py +0 -0
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-11-01: typed claim assertions
4
+
5
+ Eight independent audits (HG-020 → HG-028) found the same class of failure, each time in a new wording: to decide
6
+ whether a count backs or contradicts a claim, the checks read words — the result's key and the claimant's
7
+ measurement name for what was counted, the statement for what is asserted and which clause a count belongs to — and
8
+ the claimant writes most of those words. Each fix narrowed one wording; the next audit found another. 0.8.0 stops
9
+ inferring that structure from words.
10
+
11
+ - **Typed counts.** A count may say what it counted: `population` (a path, `route/passenger` lies within `route`),
12
+ `property` and `unit`, beside `k`, `n`, `figure` and `op`. The result's author writes them; the instrument, on its
13
+ own results.
14
+ - **Typed assertions.** A claim may carry `asserts`, atoms `{quantifier none | all | some | exactly, population,
15
+ property, op, figure, unit, n, k, measurement}`. Each is decided against its bound count by sets on the number line
16
+ and population paths (`src/hypgraph/assertions.py`): supported, contradicted, or unresolved with the field that
17
+ blocked it. No words are read, so a true atom can't lend anything to a false one (HG-028-01) and "all 4 routes"
18
+ meets its counterexample however the set is enumerated (HG-028-02). New codes `ASSERTION_CONTRADICTED` (tracked in
19
+ conduct like `COUNT_CONTRADICTS`, `by_assertion`) and `ASSERTION_UNRESOLVED`.
20
+ - **Typed investigations.** `typed_claims: true` on an investigation (or a profile's `typed_claims`) makes atoms the
21
+ check: a statement that asserts an absolute, an extreme or a count needs an atom for each part that does
22
+ (`ASSERTION_MISSING`, with atoms drafted from the statement and its typed counts — never trusted, checked once
23
+ written), and the word interpretation stands down there: measurement names and keys aren't read for what was
24
+ counted, and the absolute / counterexample / extreme checks don't run. Numbers in the statement must still be bound;
25
+ every other check runs as before.
26
+ - **Standing.** A claim's state carries `assertions` (each atom rendered as the sentence it asserts, with its verdict
27
+ against the count as it stands now), `assertion_contradicted` when a count revised later makes an atom false (the
28
+ claim joins the revalidation queue), and `checked_by`: `typed`, `wording` or `none`. Bundles carry the verdicts, and
29
+ the verifier checks them against the rows. The Atlas claim drawer shows the atoms as the headline and the author's
30
+ sentence beneath, labelled as prose.
31
+ - **A count is whole, for everyone** (HG-028-03). `k` and `n` must be whole numbers (an int or an integral float),
32
+ finite, `0 ≤ k ≤ n`, `n ≥ 1`, on creation and on revision (`VALUE_NOT_A_COUNT`); counts used to be truncated, so 0.9
33
+ of 4 read as 0 of 4 and backed "none". A typed count must be complete (`VALUE_TYPE_INVALID`).
34
+ - **The word checks are frozen.** Outside a typed investigation they run as in 0.7.28 and are documented as heuristics;
35
+ HG-028-01 and -02 remain limits of untyped mode. Replaying the 1,900 recorded adversarial entries changes none, and
36
+ every trial's standing is the same.
37
+ - Tests: `tests/test_assertions.py` (the interval logic checked against enumeration over every op and quantifier, paths,
38
+ units, the gate, standing, bundles, the draft) and `tests/test_typed_audits.py` (92 attack sentences from the audits
39
+ and their holdouts, around fixed atoms: none changes a verdict).
40
+
3
41
  ## 0.7.28 — 2026-11-01: a figure said twice, a comparison in another tense
4
42
 
5
43
  Fixes from an independent audit of 0.7.27 (HG-027-01, -02) and from paraphrases written after the first fix; each case
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.28
3
+ Version: 0.8.0
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.28 (2026-11-01)
112
+ ## Status at 0.8.0 (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
@@ -213,6 +213,27 @@ was taken from a changelog without being checked against the code.*
213
213
  > checked against a counterexample. This is still word matching; the typed claim layer the audits recommend is not
214
214
  > built. Released as 0.7.28.
215
215
 
216
+ > **Audit of 0.7.28 (2026-11-01): the class, not the symptom.** A ninth independent audit named three defects, all
217
+ > reproduced on the PyPI 0.7.28 wheel (9 of 12 reproducer cases failed, the 3 controls passed): a true absolute about
218
+ > one thing still lent its words to a false one about another in the same statement (HG-028-01); an enumerated "all 4
219
+ > routes" skipped the counterexample check (HG-028-02); and fractional counts were truncated, so 0.9 of 4 read as 0 of
220
+ > 4 (HG-028-03). The first two are the class every audit since HG-020 found: the checks infer from the claimant's
221
+ > words what a count measured, what a claim asserts and which clause a count belongs to. That is now addressed
222
+ > structurally rather than per wording: counts carry types (population, property, unit), claims carry typed atoms, and
223
+ > an atom is decided against its count by interval logic and population paths, reading no words
224
+ > (`src/hypgraph/assertions.py`). The audit's typed-predicate recommendation is the design. Fractional and impossible
225
+ > counts are refused for everyone (`VALUE_NOT_A_COUNT`).
226
+ >
227
+ > **Where that stands.** Typed checking is opt-in per investigation (`typed_claims`), additive to `hypgraph/1`.
228
+ > Inside it, the audits' 92 attack sentences around fixed atoms change no verdict, and the interval logic is checked
229
+ > against enumeration for every op and quantifier. Outside it the word checks are frozen at 0.7.28 and labelled
230
+ > heuristic: HG-028-01 and -02 are known limits of untyped mode, closed only by typed mode. Typed checking covers
231
+ > count-based assertions (none / all / some / exactly, and extremes as pairs); comparisons between two measured arms
232
+ > and significance are still word-checked. In typed mode the prose remains the author's: a sentence that asserts
233
+ > more than its atoms is refused (`ASSERTION_MISSING`), but a detection miss leaves prose unverified rather than
234
+ > verified, and the record shows the atoms, not the sentence, as what it checked. Replaying the 1,900 recorded
235
+ > adversarial entries changes none. #102 is merged with green CI (17 of 17 checks). Released as 0.8.0.
236
+
216
237
  ## Recommendation: CONDITIONAL GO
217
238
 
218
239
  HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
@@ -20,10 +20,10 @@ correction is a new row, never an edited one.
20
20
 
21
21
  | type | code | fields (\* required) | what it is |
22
22
  |---|---|---|---|
23
- | `investigation` | INV | **title**\*, **objective**\*, success_criteria, scope, state, reviewers, constants, strict_claims, keywords, projects, doi, note, legacy_case | The study: an objective, its scope, who reviews it. Every other node belongs to one. |
23
+ | `investigation` | INV | **title**\*, **objective**\*, success_criteria, scope, state, reviewers, constants, strict_claims, typed_claims, keywords, projects, doi, note, legacy_case | The study: an objective, its scope, who reviews it. Every other node belongs to one. |
24
24
  | `question` | Q | **text**\*, why | Something you want to know. |
25
25
  | `hypothesis` | H | **statement**\*, assumptions, **predictions**\*, distinguishing, open_questions | A possible answer, with the predictions that would show it wrong. |
26
- | `claim` | CL | **statement**\*, scope, limitations, kind, measurements, evidence_note, exposure, exposure_unit, legacy | What you assert is known, with its scope and its numbers bound to results. |
26
+ | `claim` | CL | **statement**\*, scope, limitations, kind, measurements, asserts, evidence_note, exposure, exposure_unit, legacy | What you assert is known, with its scope and its numbers bound to results. |
27
27
  | `experiment` | X | **aim**\*, **protocol**\*, controls, **success_criteria**\*, method, budget, power_check, cost_minutes, seed | A planned test: aim, protocol, controls, success criteria, and what each hypothesis expects. |
28
28
  | `run` | RUN | **kind**\*, job_id, name, state, code, inputs, outputs, args, config, seed, env, started, finished, exit_code, log_sha256, note | One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument. |
29
29
  | `result` | RS | **summary**\*, kind, outcome, values, expected, limitations, power, evidence_note, legacy | What a run (or an observation, or a citation) found: values, outcome, power. |
@@ -59,6 +59,21 @@ correction is a new row, never an edited one.
59
59
  Field kinds and lengths: `hypgraph schema --json` (or `/schema/hypgraph-1.json` on the site). A claim's
60
60
  `measurements` are `{name, value | k & n | lo & hi, result: <result id>, key: <field of that result's values>}`.
61
61
 
62
+ **Counts and typed counts.** A result value with `k` or `n` is a count: `k` of `n`, both whole numbers (an int, or a
63
+ float with no fraction), finite, `0 ≤ k ≤ n`, `n ≥ 1`. It may say what it compared: `figure` and `op` (one of `<` `<=`
64
+ `=` `!=` `>=` `>`). A **typed count** also says what it counted: `population`, a path such as `route/passenger` (lower
65
+ case; `a/b` lies within `a`), `property`, an id (`length`), and optionally `unit`. `{"k": 1, "n": 4, "figure": 26,
66
+ "op": ">", "population": "route", "property": "length", "unit": "km"}` is 1 of 4 routes with length > 26 km. The
67
+ types are the result's author's; on an instrument-filed result they are the instrument's.
68
+
69
+ **Typed assertions (`asserts`).** A claim may state what it asserts as atoms, each checked against a typed count
70
+ (§7): `{quantifier, population, property, op, figure, unit?, n?, k?, measurement}`, where `quantifier` is `none`
71
+ (nothing in the population meets `property op figure`), `all` (everything does), `some` (at least one does) or
72
+ `exactly` (`k` of them do; `k` required), `n` is the population's size as the claim states it, and `measurement` is the
73
+ 0-based index of the claim's measurement whose count decides it. An extreme is two atoms: "the longest route is 26 km"
74
+ is `none` `>` 26 and `some` `>=` 26. An investigation with `typed_claims: true` (or a profile that sets it) checks its
75
+ claims this way.
76
+
62
77
  Verdicts: positive `checked`, `pass`, `reproduced`, `replicated`, `separate_implementation`, `generalizes`; negative
63
78
  `fail`, `not_reproduced`, `not_replicated`, `does_not_generalize`; and `objection`, `resolved`, `inconclusive`,
64
79
  `comment`.
@@ -169,7 +184,8 @@ published and signed, and what each check does and doesn't show: [verification.m
169
184
  verifier must check every fact in it that the rows fix: which investigations it covers (the scope's, or every
170
185
  investigation node), which investigations the list shows (each once), which nodes each investigation's graph shows
171
186
  (all of them, unless capped at 5,000) and which live (unretracted) links among them, each node's `type`, `code`, `inv`,
172
- `by`, `at`, `v`, `title`, `kind` and `state.lifecycle`, and each link's `id`, ends, versions, `rel`, `data`, `at`,
187
+ `by`, `at`, `v`, `title`, `kind` and `state.lifecycle`, each typed assertion's verdict (`state.assertions`, a function
188
+ of the claim's and its results' latest rows), and each link's `id`, ends, versions, `rel`, `data`, `at`,
173
189
  `by` and `why`. Every one of these must be **present**, not only equal where present: a viewer fills a missing fact
174
190
  with a default, and a withdrawn claim without its lifecycle would read as active. The lifecycle is derived from the
175
191
  verified rows (withdrawn when a withdrawal exists; superseded when a live `supersedes` or `corrects` replacement is
@@ -212,6 +228,11 @@ Implementations must derive these, never store them:
212
228
  - A replication edge on the run raises the profile according to its `differs`.
213
229
  - A result of `kind=cited` is external only if it `cites` a source node.
214
230
  - **basis_changed** (decisions): a `based_on` edge whose target's version has moved past the pinned `dst_v`.
231
+ - **assertions** (claims with `asserts`): each atom with its verdict against its count as the record holds it now
232
+ (§7, *Typed claims*) and its rendered sentence ("no route/passenger (of 4) has length > 26 km"). The rendered atoms
233
+ are what the record verified; the statement is the author's prose. **assertion_contradicted**: the atoms a count
234
+ revised since the claim was written now makes false. **checked_by**: `typed` when the claim's investigation checks
235
+ typed atoms, `wording` when the word checks applied, `none` with `strict_claims: false`.
215
236
  - **written blind** (results): a work order with `blind: true` opens a window at its creation event and closes it at
216
237
  the first release (return or cancel) or withdrawal of any order in its rework chain. A result its assignee
217
238
  created inside the window, and has not revised since it closed, is written blind under that order, provided the
@@ -227,6 +248,9 @@ Implementations must derive these, never store them:
227
248
  - Only the instrument files a `kind=lab` run, and only the instrument records what that run `produced`.
228
249
  - Claims must pass the checks in §7, unless written by the operator or importer, or in an investigation with
229
250
  `strict_claims: false`.
251
+ - A result's counts must be counts (`VALUE_NOT_A_NUMBER`, `VALUE_NOT_A_COUNT`), and a typed count must be complete
252
+ (`VALUE_TYPE_INVALID`: `population` a path, `property` an id, `op` one of the six, `figure` a number) — on creation
253
+ and on revision.
230
254
  - Nobody officially reviews their own node.
231
255
  - Writes into an `archived` or `published` investigation are refused (reserved authors excepted).
232
256
  - Only the importer may set a write's time. Everyone else's comes from the writer's clock.
@@ -264,6 +288,31 @@ smallest detectable difference uses 80% power at two-sided α = 0.05.
264
288
  | 10 | `EXCLUSION_WITHOUT_POWER` | `kind: exclusion`, or wording that says an effect is absent (no effect, no difference, makes no difference, does not affect, rules out, ineffective, …) in a finding, without a supporting result with `power: demonstrated` or one produced by a run of an experiment with a `power_check`. With two counts, the message gives the smallest detectable difference. |
265
289
  | 11 | `UNCORRECTED_EFFECT` | a finding asserts an effect (changes, lowers, differs, affects, significant, …) on an outcome, while a live result in the investigation holds that outcome's corrected p-value (a field named with holm / bonferroni / fdr / adjusted / corrected …) at or above 0.05, or a count of 0 outcomes surviving correction (`n_holm_sig`), and the effect word is neither qualified within 20 words as uncorrected (uncorrected, unadjusted, nominally, without correction, …) nor names the correction with a corrected value bound as a measurement. A family-wide value (`min_adjusted_p`) applies when the claim binds a nominal p < 0.05. Thresholds (`bonferroni_alpha_threshold`) are not evidence. Honest forms: bind the corrected p and say "not significant after correction", or file `kind: lead`. |
266
290
 
291
+ **Typed claims.** An atom (§2) is decided by its bound count alone; no words are read. Let S_e = {x : x op_e f_e} (what
292
+ was counted, k of n inside it, n − k outside) and S_a = {x : x op_a f_a} (what is asserted); P_e and P_a the
293
+ populations. The property must equal the count's and the unit match it (spellings of one unit are one unit; nothing is
294
+ converted); a stated `n` must equal the count's.
295
+
296
+ - `none` S_a: **supported** when a side of the count with 0 inside it contains S_a and P_a lies within P_e;
297
+ **contradicted** when a side with more than 0 lies inside S_a and P_e lies within P_a.
298
+ - `some` S_a: supported when a side with more than 0 lies inside S_a and P_e within P_a; contradicted when a side with
299
+ 0 contains S_a and P_a lies within P_e.
300
+ - `all` S_a is `none` of the complement of S_a. `exactly` k needs the same population and the same set (or its
301
+ complement, counted n − k), then compares k.
302
+ - Anything else is **unresolved**, and names the field that blocked it.
303
+
304
+ | Code | Refused when |
305
+ |---|---|
306
+ | `ASSERTION_CONTRADICTED` | an atom is false by its count. The refusal log tracks it like a count contradiction (§8). |
307
+ | `ASSERTION_UNRESOLVED` | an atom its count can't settle: another property or unit, a population neither within nor containing the count's, a condition the count's doesn't decide, an untyped count, a malformed atom. |
308
+ | `ASSERTION_MISSING` | in a `typed_claims` investigation, the statement has more parts that assert an absolute, an extreme or a count than the claim has atoms. Detection reads words only to ask for atoms; a missed word leaves prose unverified, never an atom verified. The refusal proposes atoms drafted from the statement and the claim's typed counts, which are checked like any other once written. |
309
+
310
+ Atoms are checked in every investigation that has them. In a `typed_claims` investigation the word interpretation
311
+ stands down: a measurement's name and key are not read for what was counted, and rule 5 (absolutes, with the
312
+ counterexample, extreme and unreached-figure checks behind it) does not run. Every other rule runs as before. Outside
313
+ one, rule 5 and those checks are heuristics, frozen as of 0.7.28: they read a claim's words for what a count measured,
314
+ what the claim asserts and which clause a count belongs to, and the claimant writes those words.
315
+
267
316
  **Exposure.** A claim may set `exposure` (how many uses, users or units it will be applied to) and `exposure_unit`.
268
317
  Absolute refusals then state how many failures the bound leaves unruled out at that scale. An accepted claim's
269
318
  standing carries `bounds` (per count: k, n, rate, lo, hi and, with exposure, `unruled_out`), its sentence reads
@@ -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.28"
19
+ __version__ = "0.8.0"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -0,0 +1,384 @@
1
+ """
2
+ Typed claim assertions: what a claim asserts about a count, stated as data and checked against the count's own typed
3
+ fields. Nothing here reads words.
4
+
5
+ The word checks (checks.py) work out from prose what a count measured, what a claim asserts and which clause a count
6
+ belongs to. The claimant writes most of those words, and eight audits (HG-020 → HG-028) each found a new wording that
7
+ steered one of those inferences. Here the instrument types what it counted and the claimant types what it asserts:
8
+
9
+ a count {"k": 1, "n": 4, "figure": 26, "op": ">", "population": "route/passenger", "property": "length",
10
+ "unit": "km"} — 1 of the 4 passenger routes has length > 26 km
11
+ an atom {"quantifier": "none", "population": "route/passenger", "property": "length", "op": ">",
12
+ "figure": 26, "unit": "km", "n": 4, "measurement": 0} — no passenger route is longer than 26 km,
13
+ checked against the claim's first measurement
14
+
15
+ quantifier none (nothing in the population meets ``property op figure``) · all (everything does) · some (at least
16
+ one does) · exactly (``k`` of them do). An extreme is two atoms: "the highest is 93" is none > 93 and
17
+ some >= 93.
18
+ population a path: ``route/passenger`` lies within ``route``, so a count of 0 over every route also settles
19
+ "no passenger route"; a count of the passenger routes says nothing of the cargo routes.
20
+ property an id, compared for equality ("length" is not "speed").
21
+ unit compared for equality, spellings of one unit read as one ("km" = "kilometres"); never converted.
22
+ n optional: the population's size as the claim states it; must be the count's n.
23
+
24
+ Each atom is decided on its own, by sets on the number line — S_e = {x : x op_e figure_e} (what was counted) and
25
+ S_a = {x : x op_a figure_a} (what is asserted) — and the paths. A count of k in S_e is also a count of n - k outside it.
26
+
27
+ none S_a supported when some count of 0 is over a set containing S_a and a population containing the atom's;
28
+ contradicted when a count above 0 is over a set inside S_a and a population inside the atom's
29
+ some S_a the mirror: a count above 0 inside S_a supports it, a count of 0 containing S_a contradicts it
30
+ all S_a none of the complement of S_a
31
+ exactly the same set and the same population, then k compared
32
+
33
+ Anything else is unresolved, and the verdict names the field that blocked it. One count can back two atoms only if
34
+ each checks out against it on its own, so a true absolute can't lend anything to a false one (HG-028-01), and "all"
35
+ is checked against its counterexample whatever the sentence enumerates (HG-028-02).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import re
42
+
43
+ from . import stats
44
+ from .derive import Snap, result_value
45
+
46
+ OPS = ("<", "<=", "=", "!=", ">=", ">")
47
+ COMPLEMENT = {"<": ">=", "<=": ">", "=": "!=", "!=": "=", ">=": "<", ">": "<="}
48
+ QUANTIFIERS = ("none", "all", "some", "exactly")
49
+ FIELDS = ("quantifier", "population", "property", "op", "figure", "unit", "n", "k", "measurement")
50
+ SHOW_OP = {"<": "<", "<=": "≤", "=": "=", "!=": "≠", ">=": "≥", ">": ">"}
51
+ _SEGMENT = re.compile(r"^[a-z0-9][a-z0-9 _.-]*$")
52
+
53
+
54
+ def _num(x) -> bool:
55
+ return isinstance(x, (int, float)) and not isinstance(x, bool) and x == x and abs(x) != float("inf")
56
+
57
+
58
+ def path(p) -> tuple[str, ...] | None:
59
+ """A population path as its segments (lower-cased, trimmed), or None when it isn't one."""
60
+ if not isinstance(p, str) or not p.strip():
61
+ return None
62
+ segs = tuple(" ".join(x.strip().lower().split()) for x in p.strip().strip("/").split("/"))
63
+ return segs if all(_SEGMENT.match(x) for x in segs) else None
64
+
65
+
66
+ def within(inner: tuple, outer: tuple) -> bool:
67
+ """Does every member of ``inner`` belong to ``outer``? route/passenger lies within route."""
68
+ return inner[:len(outer)] == outer
69
+
70
+
71
+ def prop(p) -> str | None:
72
+ """A property id, compared for equality: case, spaces, hyphens and underscores don't matter."""
73
+ if not isinstance(p, str) or not p.strip():
74
+ return None
75
+ return re.sub(r"[\s_-]+", "_", p.strip().lower())
76
+
77
+
78
+ def _unit(u) -> frozenset | None:
79
+ """A unit as the set of names it could be ("km" → {kilometre}); None when there's none."""
80
+ if u is None or (isinstance(u, str) and not u.strip()):
81
+ return None
82
+ from .checks import _UNIT_OF # one table of unit spellings, shared with the word checks
83
+ w = str(u).strip().lower()
84
+ return frozenset(c for _f, c in _UNIT_OF.get(w, ())) or frozenset({w})
85
+
86
+
87
+ def same_unit(a, b) -> bool:
88
+ ua, ub = _unit(a), _unit(b)
89
+ return (ua is None and ub is None) or (ua is not None and ub is not None and bool(ua & ub))
90
+
91
+
92
+ def holds(x: float, op: str, f: float) -> bool:
93
+ return {"<": x < f, "<=": x <= f, "=": x == f, "!=": x != f, ">=": x >= f, ">": x > f}[op]
94
+
95
+
96
+ def subset(a: tuple, b: tuple) -> bool:
97
+ """Is {x : x a[0] a[1]} inside {x : x b[0] b[1]}? Both sets are unions of intervals with ends at the two figures,
98
+ so membership is constant on each piece between them: checking one point per piece decides it."""
99
+ pts = sorted({float(a[1]), float(b[1])})
100
+ probes = pts + [pts[0] - 1, pts[-1] + 1] + [(x + y) / 2 for x, y in zip(pts, pts[1:])]
101
+ return all(holds(x, *b) for x in probes if holds(x, *a))
102
+
103
+
104
+ def same_set(a: tuple, b: tuple) -> bool:
105
+ return subset(a, b) and subset(b, a)
106
+
107
+
108
+ def count_fields(rv) -> tuple[dict | None, str]:
109
+ """A result value as a typed count {k, n, op, figure, population, property, unit}, or (None, what's missing)."""
110
+ if not isinstance(rv, dict):
111
+ return None, "the bound value is not a count"
112
+ k, n = rv.get("k"), rv.get("n")
113
+ if not (stats.whole(k) and stats.whole(n) and n >= 1 and 0 <= k <= n):
114
+ return None, "the bound value is not a count (whole k of n)"
115
+ for f in ("population", "property"):
116
+ if not rv.get(f):
117
+ return None, f"the count has no {f}: the result's author types what was counted"
118
+ if rv.get("op") not in OPS:
119
+ return None, "the count has no op (one of < <= = != >= >)"
120
+ if not _num(rv.get("figure")):
121
+ return None, "the count has no figure"
122
+ pop = path(rv["population"])
123
+ if pop is None:
124
+ return None, "the count's population is not a path (route/passenger)"
125
+ return {"k": int(k), "n": int(n), "op": rv["op"], "figure": float(rv["figure"]), "population": pop,
126
+ "property": prop(rv["property"]), "unit": rv.get("unit")}, ""
127
+
128
+
129
+ def atom_shape(a, meas: list | None) -> tuple[str, str]:
130
+ """(field, why) when an atom is malformed; ("", "") when it is well formed. ``meas`` None: the measurement index
131
+ isn't checked."""
132
+ if not isinstance(a, dict):
133
+ return "atom", "an assertion is an object {quantifier, population, property, op, figure, measurement}"
134
+ extra = sorted(set(a) - set(FIELDS))
135
+ if extra:
136
+ return extra[0], f"unknown field {extra[0]!r} (fields: {', '.join(FIELDS)})"
137
+ if a.get("quantifier") not in QUANTIFIERS:
138
+ return "quantifier", "quantifier must be one of none, all, some, exactly"
139
+ if path(a.get("population")) is None:
140
+ return "population", "population must be a path such as route or route/passenger"
141
+ if prop(a.get("property")) is None:
142
+ return "property", "property must name what is compared (length, speed, wait)"
143
+ if a.get("op") not in OPS:
144
+ return "op", "op must be one of < <= = != >= >"
145
+ if not _num(a.get("figure")):
146
+ return "figure", "figure must be a number"
147
+ if "unit" in a and a["unit"] is not None and not isinstance(a["unit"], str):
148
+ return "unit", "unit must be text"
149
+ if "n" in a and not (stats.whole(a["n"]) and a["n"] >= 1):
150
+ return "n", "n must be a whole number of at least 1"
151
+ if a["quantifier"] == "exactly" and not (stats.whole(a.get("k")) and a["k"] >= 0):
152
+ return "k", "'exactly' needs k, a whole number"
153
+ if "k" in a and a["quantifier"] != "exactly":
154
+ return "k", "k is for 'exactly'; none / all / some say it themselves"
155
+ i = a.get("measurement")
156
+ if meas is None:
157
+ return "", ""
158
+ if not (stats.whole(i) and 0 <= i < len(meas) and isinstance(meas[int(i)], dict)):
159
+ return "measurement", f"measurement must index the claim's measurements (0..{len(meas) - 1})" if meas else \
160
+ "measurement must index the claim's measurements, and the claim binds none"
161
+ return "", ""
162
+
163
+
164
+ def _pop(p: tuple) -> str:
165
+ return "/".join(p)
166
+
167
+
168
+ def _cond(a: dict) -> str:
169
+ u = f" {a['unit']}" if a.get("unit") else ""
170
+ fig = stats_fmt(a["figure"])
171
+ return f"{a['property']} {SHOW_OP.get(a['op'], a['op'])} {fig}{u}"
172
+
173
+
174
+ def stats_fmt(v) -> str:
175
+ v = float(v)
176
+ return str(int(v)) if v.is_integer() else f"{v:g}"
177
+
178
+
179
+ def render(a: dict) -> str:
180
+ """An atom as the sentence it asserts — what the record shows as verified."""
181
+ if atom_shape(a, None)[0]:
182
+ return json.dumps(a, sort_keys=True)[:300] if isinstance(a, dict) else str(a)[:300]
183
+ pop, cond = "/".join(path(a["population"]) or ()), _cond(a)
184
+ among = f" (of {int(a['n'])})" if "n" in a else ""
185
+ q = a["quantifier"]
186
+ if q == "none":
187
+ return f"no {pop}{among} has {cond}"
188
+ if q == "all":
189
+ return f"every {pop}{among} has {cond}"
190
+ if q == "some":
191
+ return f"at least one {pop}{among} has {cond}"
192
+ return f"exactly {int(a['k'])} of {int(a['n']) if 'n' in a else 'the'} {pop} have {cond}"
193
+
194
+
195
+ def render_count(e: dict) -> str:
196
+ u = f" {e['unit']}" if e.get("unit") else ""
197
+ return (f"{e['k']} of {e['n']} {_pop(e['population'])} counted with {e['property']} "
198
+ f"{SHOW_OP[e['op']]} {stats_fmt(e['figure'])}{u}")
199
+
200
+
201
+ def _rid(s: Snap, ref: str, inv: str) -> str:
202
+ if ref in s.nodes:
203
+ return ref
204
+ hits = [i for i, n in s.nodes.items() if n["type"] == "result" and n["code"].lower() == ref.lower()
205
+ and (not inv or n["inv"] == inv)]
206
+ return hits[0] if len(hits) == 1 else ref
207
+
208
+
209
+ def _decide(a: dict, e: dict) -> tuple[str, str, str]:
210
+ """(verdict, field that blocked it, why) for a well-formed atom against a typed count."""
211
+ pa, pe = path(a["population"]), e["population"]
212
+ if prop(a["property"]) != e["property"]:
213
+ return "unresolved", "property", (f"the count is of {e['property']}, the assertion is about "
214
+ f"{prop(a['property'])}")
215
+ if not same_unit(a.get("unit"), e.get("unit")):
216
+ return "unresolved", "unit", (f"the count is in {e.get('unit') or 'no unit'}, the assertion in "
217
+ f"{a.get('unit') or 'no unit'}: state the figure in the count's unit")
218
+ if not (within(pa, pe) or within(pe, pa)):
219
+ return "unresolved", "population", (f"the count is of {_pop(pe)}, the assertion is about {_pop(pa)}: "
220
+ "neither lies within the other")
221
+ if "n" in a and int(a["n"]) != e["n"]:
222
+ if pa == pe:
223
+ return "contradicted", "n", f"the assertion says {int(a['n'])} {_pop(pa)}; the count has {e['n']}"
224
+ return "unresolved", "n", (f"the assertion's n ({int(a['n'])}) is the size of {_pop(pa)}, which the count of "
225
+ f"{_pop(pe)} ({e['n']}) doesn't give")
226
+ # what was counted, both ways: k inside S_e and n - k outside it
227
+ sides = [((e["op"], e["figure"]), e["k"]), ((COMPLEMENT[e["op"]], e["figure"]), e["n"] - e["k"])]
228
+ q, t = a["quantifier"], (a["op"], float(a["figure"]))
229
+ got = _settle(q, t, sides, pa, pe, a)
230
+ if got:
231
+ return got
232
+ if _settle(q, t, sides, pa, pa, a): # it would settle over the same population: the paths blocked it
233
+ return "unresolved", "population", (f"a count over {_pop(pe)} doesn't settle this about {_pop(pa)} (a count "
234
+ "of a larger population settles 'none' and 'all'; a counterexample in a "
235
+ "smaller one settles 'some' and refutes 'none')")
236
+ return "unresolved", "op", (f"the count compared {e['property']} {SHOW_OP[e['op']]} {stats_fmt(e['figure'])}, "
237
+ f"which settles neither way whether {render(a)}")
238
+
239
+
240
+ def _settle(q: str, t: tuple, sides: list, pa: tuple, pe: tuple, a: dict) -> tuple[str, str, str] | None:
241
+ """The verdict when the count settles the atom, else None."""
242
+ if q == "exactly":
243
+ if pa != pe:
244
+ return None
245
+ for side, c in sides:
246
+ if same_set(t, side):
247
+ ok = int(a["k"]) == c
248
+ return ("supported" if ok else "contradicted", "" if ok else "k",
249
+ f"the count finds {c}" + ("" if ok else f", not {int(a['k'])}"))
250
+ return None
251
+ neg = "that don't" if q == "all" else "that do"
252
+ if q == "all": # everything meets it: nothing meets its complement
253
+ q, t = "none", (COMPLEMENT[t[0]], t[1])
254
+ if q == "none":
255
+ for side, c in sides:
256
+ if c > 0 and subset(side, t) and within(pe, pa):
257
+ return "contradicted", "", f"the count finds {c} {neg}"
258
+ for side, c in sides:
259
+ if c == 0 and subset(t, side) and within(pa, pe):
260
+ return "supported", "", f"the count finds none {neg}"
261
+ else: # some
262
+ for side, c in sides:
263
+ if c > 0 and subset(side, t) and within(pe, pa):
264
+ return "supported", "", f"the count finds {c}"
265
+ for side, c in sides:
266
+ if c == 0 and subset(t, side) and within(pa, pe):
267
+ return "contradicted", "", "the count finds none"
268
+ return None
269
+
270
+
271
+ def verify(s: Snap, body: dict, inv: str = "") -> list[dict]:
272
+ """Each atom of a claim with its verdict: supported | contradicted | unresolved, the rendered sentence, the
273
+ count it was decided on and, when it wasn't supported, the field and the reason."""
274
+ def lookup(ref: str, key: str) -> tuple[str, dict | None, int | None]:
275
+ rid = _rid(s, ref, inv)
276
+ return rid, result_value(s, rid, key), s.nodes[rid]["v"] if rid in s.nodes else None
277
+ return verify_with(body, lookup)
278
+
279
+
280
+ def verify_with(body: dict, lookup) -> list[dict]:
281
+ """``verify`` over any store: ``lookup(result, key)`` gives (result id, the value under key or None, its
282
+ version). The bundle verifier passes the verified rows, so a view can't show an atom supported that its rows
283
+ make false."""
284
+ meas = body.get("measurements") or []
285
+ out = []
286
+ for i, a in enumerate(body.get("asserts") or []):
287
+ row = {"atom": i, "text": render(a)}
288
+ field, why = atom_shape(a, meas)
289
+ if field:
290
+ out.append({**row, "verdict": "unresolved", "field": field, "why": why})
291
+ continue
292
+ m = meas[int(a["measurement"])]
293
+ rid, rv, ver = lookup(str(m.get("result") or ""), str(m.get("key") or ""))
294
+ e, why = count_fields(rv)
295
+ row.update(result=rid, key=str(m.get("key") or ""), version=ver)
296
+ if e is None:
297
+ out.append({**row, "verdict": "unresolved", "field": "measurement", "why": why})
298
+ continue
299
+ verdict, field, why = _decide(a, e)
300
+ out.append({**row, "verdict": verdict, "field": field, "why": why, "k": e["k"], "n": e["n"],
301
+ "counted": render_count(e)})
302
+ return out
303
+
304
+
305
+ def key(a) -> str:
306
+ """What an atom asserts, as a stable string: the same assertion written again has the same key, whatever its
307
+ measurement or spelling of the unit."""
308
+ if atom_shape(a, None)[0]:
309
+ return json.dumps(a, sort_keys=True, default=str)[:300]
310
+ u = _unit(a.get("unit"))
311
+ return json.dumps([a["quantifier"], "/".join(path(a["population"]) or ()), prop(a["property"]), a["op"],
312
+ float(a["figure"]), sorted(u) if u else None, a.get("n"), a.get("k")], default=str)
313
+
314
+
315
+ def numbers(body: dict) -> list[float]:
316
+ """The figures, k and n the atoms state: a statement may say them."""
317
+ out: list[float] = []
318
+ for a in body.get("asserts") or []:
319
+ if isinstance(a, dict):
320
+ out += [float(a[f]) for f in ("figure", "n", "k") if _num(a.get(f))]
321
+ return out
322
+
323
+
324
+ # ── the drafting aid: proposes atoms from the prose and the typed counts; never trusted ────────────────────────────
325
+
326
+ def _op_of(comp: tuple | None) -> str | None:
327
+ if not comp:
328
+ return None
329
+ _axis, sign, strict = comp
330
+ return (">" if strict else ">=") if sign > 0 else ("<" if strict else "<=")
331
+
332
+
333
+ def draft(s: Snap, body: dict, inv: str = "") -> list[dict]:
334
+ """Atoms a claim's statement seems to assert, filled from its typed counts. The word checks' parsers propose them;
335
+ nothing proposed here is trusted — each must be written into ``asserts`` by the claimant and is then checked like
336
+ any other. Empty when nothing can be proposed."""
337
+ from . import checks as C
338
+ from . import language as L
339
+ stmt = body.get("statement", "") or ""
340
+ meas = body.get("measurements") or []
341
+ typed = []
342
+ for i, m in enumerate(meas):
343
+ if isinstance(m, dict):
344
+ rid = _rid(s, str(m.get("result") or ""), inv)
345
+ e, _ = count_fields(result_value(s, rid, str(m.get("key") or "")))
346
+ if e:
347
+ typed.append((i, e))
348
+ if not typed:
349
+ return []
350
+ out: list[dict] = []
351
+
352
+ def add(a: dict, e: dict) -> None:
353
+ if e.get("unit"):
354
+ a["unit"] = e["unit"]
355
+ if a not in out:
356
+ out.append(a)
357
+
358
+ for part in C.asserting_parts(stmt): # one clause at a time: its own quantifier, its own figures
359
+ said = [w.lower() for w in L.asserted(L.ABSOLUTE, part)]
360
+ if said:
361
+ q = "all" if any(C._ALL.match(w) for w in said) else "none"
362
+ for fig, comp in C._clause_comparisons(part, after_no=True):
363
+ op = _op_of(comp)
364
+ if op is None:
365
+ continue
366
+ # the counts made at this figure, when there are any: the figure is the one fact the clause and
367
+ # the count share without reading what either is about
368
+ pool = [(i, e) for i, e in typed if e["figure"] == fig] or typed
369
+ for i, e in pool:
370
+ add({"quantifier": q, "population": _pop(e["population"]), "property": e["property"], "op": op,
371
+ "figure": int(fig) if float(fig).is_integer() else fig, "n": e["n"], "measurement": i}, e)
372
+ figs = {v for v, *_ in C.numbers(part)}
373
+ for k, n in ((int(a.replace(",", "")), int(b.replace(",", ""))) for a, b in C._STATED_COUNT.findall(part)):
374
+ # what the clause says, true or not — the check decides; counts at a figure the clause names first
375
+ pool = [(i, e) for i, e in typed if e["n"] == n]
376
+ for i, e in [x for x in pool if x[1]["figure"] in figs] or pool:
377
+ add({"quantifier": "exactly", "population": _pop(e["population"]), "property": e["property"],
378
+ "op": e["op"], "figure": int(e["figure"]) if e["figure"].is_integer() else e["figure"],
379
+ "k": k, "n": n, "measurement": i}, e)
380
+ return out[:6]
381
+
382
+
383
+ __all__ = ["COMPLEMENT", "OPS", "QUANTIFIERS", "atom_shape", "count_fields", "draft", "holds", "key", "numbers", "path",
384
+ "render", "render_count", "same_set", "same_unit", "subset", "verify", "verify_with", "within"]
@@ -33,7 +33,7 @@ import os
33
33
  from pathlib import Path
34
34
  from typing import TYPE_CHECKING, Any
35
35
 
36
- from . import __version__
36
+ from . import __version__, assertions
37
37
  from .schema import RELS, SAME_TYPE
38
38
  from .store import GENESIS, ROW_TABLES, check_rows, event_body, now
39
39
 
@@ -349,8 +349,9 @@ def _derived_problem(b: dict, tables: dict, historical: list | None = None, unco
349
349
  redacted: int = 0) -> str:
350
350
  """The derived views are presentation, but a viewer shows them beside a verified chain: every fact in them that
351
351
  the rows fix — which investigations there are, which nodes and live links each has, each node's type, code,
352
- author, time, version, title and lifecycle, each link's ends and fields — must be present and agree with the
353
- verified rows. A view that leaves a fact out fails: a viewer would fill the gap with a default (a withdrawn claim
352
+ author, time, version, title and lifecycle, each link's ends and fields, and each typed assertion's verdict (a
353
+ function of the claim's and its results' rows, assertions.py) — must be present and agree with the verified
354
+ rows. A view that leaves a fact out fails: a viewer would fill the gap with a default (a withdrawn claim
354
355
  shown as active). The rest of standing can't be checked this way.
355
356
 
356
357
  A lifecycle is checked against the current rule only. One that matches only 0.3's rule (the end of a chain of
@@ -414,6 +415,17 @@ def _derived_problem(b: dict, tables: dict, historical: list | None = None, unco
414
415
  st = x.get("state")
415
416
  if not isinstance(st, dict) or "lifecycle" not in st:
416
417
  return f"{where} shows {name} without its lifecycle"
418
+ if n["type"] == "claim" and body.get("asserts"):
419
+ def lookup(ref: str, key: str) -> tuple:
420
+ ver, rb = latest.get(ref, (None, {}))
421
+ rv = (rb.get("values") or {}).get(key) if ref in nodes and nodes[ref]["type"] == "result" else None
422
+ rv = {"value": rv} if isinstance(rv, (int, float)) and not isinstance(rv, bool) else rv
423
+ return ref, rv if isinstance(rv, dict) else None, ver
424
+ want_v = [a["verdict"] for a in assertions.verify_with(body, lookup)]
425
+ got_v = [a.get("verdict") if isinstance(a, dict) else None for a in st.get("assertions") or []]
426
+ if got_v != want_v:
427
+ return (f"{where} shows {name}'s typed assertions as {_j(got_v)}; its rows make them {_j(want_v)}"
428
+ if got_v else f"{where} shows {name} without its typed assertions")
417
429
  w, shown = lifecycle(n["id"], True), st["lifecycle"]
418
430
  if shown == w:
419
431
  return ""