hypgraph 0.8.6__tar.gz → 0.8.8__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.
- {hypgraph-0.8.6 → hypgraph-0.8.8}/CHANGELOG.md +75 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/PKG-INFO +1 -1
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/PLAN.md +1 -1
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/RELEASE-READINESS.md +21 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/checks.py +58 -4
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/graph.py +80 -19
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/language.py +25 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/refusals.py +35 -15
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/report_check.py +4 -3
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/workflow.py +5 -0
- hypgraph-0.8.8/tests/test_bound_wording.py +88 -0
- hypgraph-0.8.8/tests/test_consistent_load.py +76 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_contradictions.py +84 -0
- hypgraph-0.8.8/tests/test_implied_absolutes.py +69 -0
- hypgraph-0.8.8/tests/test_link_rechecks.py +86 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_measure_questions.py +16 -2
- {hypgraph-0.8.6 → hypgraph-0.8.8}/.gitignore +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/AGENTS.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/CITATION.cff +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/DISCLAIMER.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/LICENSE +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/NOTICE +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/README.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/site-plan.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/spec.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/ui-plan.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/verification.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/workflows/README.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/workflows/software.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/docs/workflows/trading.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/examples/frameworks/README.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/pyproject.toml +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/assertions.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/blind.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/cli.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/demos/dispute.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/demos/linked.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/references.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/schema.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/server.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/store.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.i18n.js +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_anchoring.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_assertions.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_blind.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_blind_oracles.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_borrowed_words.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_bound_support.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_coord.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_counts_that_bear.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_d10.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_demo_dispute.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_direction_and_units.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_engine.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_figure_said_twice.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_framework_examples.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_i18n.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_incremental.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_link_translate.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_linked_records.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_next_order.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_option_lead.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_order_detail.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_profiles.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_quantifiers_and_identity.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_quoted_counterexample.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_references.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_refusals.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_relation_words.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_report_answers.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_report_significance_denied.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_server_auth.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_statement_is_a_question.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_statement_slots.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_stats_values.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_surfaces.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_trust_boundary.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_typed_audits.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_ui_app.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_ui_sources_focus.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_verify_complete.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_verify_lifecycle.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_verify_scope.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_verify_scoped_views.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_verify_trust.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_verify_views.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_what_a_count_is_of.py +0 -0
- {hypgraph-0.8.6 → hypgraph-0.8.8}/tests/test_workflows.py +0 -0
|
@@ -2,6 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.8 — 2026-11-15: the record tells a liar who keeps at it from one who moved on
|
|
6
|
+
|
|
7
|
+
No change to the bundle format. One check closes a back door (a link is checked like the claim's create); the
|
|
8
|
+
rest were found by small showcase experiments on gpt-oss-20b (BC-250), where a paid critic argued against an
|
|
9
|
+
honest researcher and a writer drafted launch headlines from test counts. The conduct log now counts a lie said
|
|
10
|
+
again beside a figure it denies as a repeat, and a new false figure each time as persisting (`conduct()` gains
|
|
11
|
+
`replaced_false`); the bound a refusal suggests is accepted; "flawlessly" and "without a single error" are
|
|
12
|
+
absolutes. Each was checked by replaying the experiments' logs, with no model calls: on 0.8.7 the replays
|
|
13
|
+
reproduce the runs exactly.
|
|
14
|
+
|
|
15
|
+
- **A link that adds support to a claim is checked like the claim's create.** Claim checks ran when a claim was
|
|
16
|
+
created or revised, never when a later `link` added to what it rests on. So any check that reads the support
|
|
17
|
+
(a source that says the opposite, a count against an absolute, a profile's source rules) could be stepped round:
|
|
18
|
+
write the claim on harmless support, then link the rest. In a HypSwarm swarm on gpt-oss-20b (BC-250, 2026-11-10)
|
|
19
|
+
an inconclusive answer was created on a result, and its `cites` link to an unreferenced source was added 53 s
|
|
20
|
+
later. It was reviewed checked. A non-curator's link that changes a claim's support now runs `checks.claim_issues`
|
|
21
|
+
and the profile's claim checks with and without the link. The link is refused only for problems it brings; one the
|
|
22
|
+
claim already had doesn't block an unrelated link. The refusal is logged as `op=link` on the claim. Replayed over
|
|
23
|
+
270 swarm records on the released profiles, 0 of 5,079 later links would be refused.
|
|
24
|
+
- **A lie said again beside a figure it denies or compares is a repeat.** A count contradiction stored every number
|
|
25
|
+
in the refused statement as part of the lie, including a denied or compared one ("40 km, **not 38 km**", "31 km,
|
|
26
|
+
since the true total **exceeds 30 km**"). `refusals.carried` never counts those as asserted, so the same sentence
|
|
27
|
+
said again was logged as moving off it, not as a repeat. The refusal now stores only the figures the statement
|
|
28
|
+
asserts (`checks.asserted_figures`, one pattern for both sides), and `carried` reads refusals logged before this
|
|
29
|
+
through the refused statement. Found in the spot-the-bad-actor showcase (gpt-oss-20b, BC-250, 2026-11-15): replaying
|
|
30
|
+
its 20 record-arm disputes, one paid critic goes from 0 repeats and 5 moves off to 3 repeats, still saying it at
|
|
31
|
+
the end. No other party's conduct changes.
|
|
32
|
+
- **A new false figure each time reads as persisting, not moving off.** An author refused six times with a different
|
|
33
|
+
figure each time (31, 32, 33 km ...) had every refusal logged as moving off the last one, and `conduct()` summed it
|
|
34
|
+
up as "the author did not say it again". `conduct()` now counts `replaced_false`: claims shown false whose author's
|
|
35
|
+
last word on them was a move into another claim the record also showed false. It is a subset of `moved_off`, so
|
|
36
|
+
existing fields keep their meaning. The sentence says it ("did not say the same claim again, but replaced a
|
|
37
|
+
contradicted claim with another the record also contradicted 5 times, and never corrected it"). Derived on read
|
|
38
|
+
from the log, so existing records read the new way. Found in the spot-the-bad-actor showcase (gpt-oss-20b, BC-250,
|
|
39
|
+
2026-11-15), where a tie-breaker reading the record acquitted such a critic. Replaying its 20 record-arm disputes:
|
|
40
|
+
6 of 10 paid critics get a replacement count, no honest party does, and no verdict of the record rule changes.
|
|
41
|
+
- **The honest wording a refusal suggests is accepted.** `ABSOLUTE_UNDERPOWERED` suggests "no occurrences in 8
|
|
42
|
+
trials (95% upper bound 31%)", and `bound()` prints "at most 31%". The record then refused that figure as unbound,
|
|
43
|
+
for two reasons. Statistical figures had to be written to a decimal place, and for k = n the printed bound
|
|
44
|
+
(1 − lower bound) wasn't among the derived numbers at all. A percentage the statement itself labels as a bound
|
|
45
|
+
("upper bound", "at most", "no more than" …) now matches a bound count's 95% bounds, or their complements, as
|
|
46
|
+
printed. The match is by position, so a labelled bound vouches for nothing else in the sentence. Unlabelled
|
|
47
|
+
figures keep the decimal rule. `check_text` does the same. Found in the say-no-more-than-the-data showcase
|
|
48
|
+
(gpt-oss-20b, BC-250, 2026-11-15). Replaying its 82 headline attempts, 11 that were refused are now accepted, all
|
|
49
|
+
of them correct bounds for their counts, and no other outcome changes.
|
|
50
|
+
- **"Flawlessly" and "without a single error" are absolutes.** They say of how things went what "every" says of
|
|
51
|
+
how many, and they weren't in `language.ABSOLUTE`. So "Real-world files parsed flawlessly" on 76 of 77 went in,
|
|
52
|
+
while "every real-world file parsed" on the same count was refused. Added: flawless(ly), without (a single / any /
|
|
53
|
+
one) error / failure / fault / crash / exception / bug / mistake / glitch / hitch, and error- / failure- / fault- /
|
|
54
|
+
crash- / bug-free. These words describe the manner of their subject, so they cover every one of it only when the
|
|
55
|
+
subject does: "nearly all files parsed flawlessly", "72 of 73 parsed without error" and "flawlessly, only a handful
|
|
56
|
+
slipped" stay true and still go in. Found in the say-no-more-than-the-data showcase (gpt-oss-20b, BC-250,
|
|
57
|
+
2026-11-15). Replaying its 82 headline attempts, the 4 false drafts that used these words are refused, and no
|
|
58
|
+
other outcome changes. None of the 2,646 HypSwarm records on hand has a claim using them.
|
|
59
|
+
|
|
60
|
+
## 0.8.7 — 2026-11-08: a snapshot is loaded at one moment
|
|
61
|
+
|
|
62
|
+
No change to the bundle format or the checks: one read fix and one scope fix, both found running HypSwarm.
|
|
63
|
+
|
|
64
|
+
- **A snapshot is loaded at one moment.** The tables behind a snapshot were each read in their own implicit
|
|
65
|
+
transaction, so a second Graph on the same file could commit a node and its edge between the `nodes` read and
|
|
66
|
+
the `edges` read; the edge then named a node the snapshot didn't hold, was skipped, and its mark was advanced
|
|
67
|
+
past it — that Graph never saw the edge again until a rebuild. hypswarm's scheduler and the MCP server an
|
|
68
|
+
external seat works through share a file this way (one process, two threads); on Ubuntu CI the scheduler found
|
|
69
|
+
"no live claim" on a question every other Graph showed answered, reworked the order, and asked the seat to count
|
|
70
|
+
again, which the record refused as a restatement (2026-11-05 to 11-08, never on Windows). A rebuild, a delta
|
|
71
|
+
(with its retraction check) and the row load now run inside one read transaction; a load inside a write's own
|
|
72
|
+
transaction joins it. Reproduced deterministically before the fix (`tests/test_consistent_load.py`).
|
|
73
|
+
- **The run behind a result on a question is a run on the question.** A question's scope collected results through
|
|
74
|
+
its claims (`derived_from`, `supports`) but runs only through an experiment's `executes` or a `part_of` link. A
|
|
75
|
+
harness that registers run → result → claim and links only the claim to the question left the run gate reading
|
|
76
|
+
"no run executes an experiment on Q1" with the result and the claim both met; the harness issued a `run` order
|
|
77
|
+
every round beside the review, and on one BC-250 run the seats filed 11 claims on 3 questions without settling
|
|
78
|
+
one (S3, 2026-11-07). The scope now adds the runs that `produced` its results.
|
|
79
|
+
|
|
5
80
|
## 0.8.6 — 2026-11-07: a placeholder is not a statement
|
|
6
81
|
|
|
7
82
|
- **A figure's name is not the figure.** The slot check (`STATEMENT_EMPTY`) now refuses `$mean_tutored`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.8
|
|
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.8.
|
|
112
|
+
## Status at 0.8.8 (2026-11-15)
|
|
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
|
|
@@ -268,6 +268,27 @@ was taken from a changelog without being checked against the code.*
|
|
|
268
268
|
> them checked), while a name beside its value passes. Tests: `tests/test_statement_slots.py`,
|
|
269
269
|
> `tests/test_statement_is_a_question.py`. #115 and #116 are merged with green CI. Released as 0.8.6.
|
|
270
270
|
|
|
271
|
+
> **After 0.8.7 (2026-11-15).** No change to the bundle format. A link that adds support to a claim runs the
|
|
272
|
+
> claim checks, so a check can't be stepped round by linking after the create (#121). Four fixes from showcase
|
|
273
|
+
> experiments on gpt-oss-20b (BC-250): a refusal no longer stores a denied or compared figure as part of the lie,
|
|
274
|
+
> so the same sentence said again is a repeat (#122); `conduct()` counts `replaced_false`, a false claim left only
|
|
275
|
+
> for another false one, and says so (#123); a percentage a statement labels as a bound matches a count's 95%
|
|
276
|
+
> bounds as printed, so the honest wording `ABSOLUTE_UNDERPOWERED` and `bound()` suggest is accepted (#124);
|
|
277
|
+
> "flawlessly", "without a single error" and "-free" are absolutes unless their subject is a part (#125).
|
|
278
|
+
> Replaying the experiments' logs: one paid critic's repeats 0 → 3, six get a replacement count, no honest party
|
|
279
|
+
> changes; 11 correct bounds now accepted and 4 false headlines now refused, nothing else moves. Tests:
|
|
280
|
+
> `tests/test_contradictions.py`, `tests/test_bound_wording.py`, `tests/test_implied_absolutes.py`,
|
|
281
|
+
> `tests/test_link_rechecks.py`. #121–#125 are merged with green CI. Released as 0.8.8.
|
|
282
|
+
|
|
283
|
+
> **After 0.8.6 (2026-11-08).** No change to the bundle format or the checks. A snapshot's tables were each read in
|
|
284
|
+
> their own implicit transaction, so a second Graph on the same file could commit a node and its edge between the
|
|
285
|
+
> `nodes` read and the `edges` read; the edge was skipped and its mark advanced past it, lost to that Graph until a
|
|
286
|
+
> rebuild (HypSwarm's scheduler beside its MCP server, on Ubuntu CI). Rebuild, delta and row load now run inside one
|
|
287
|
+
> read transaction; `tests/test_consistent_load.py` reproduces the loss on the old code. And a question's scope
|
|
288
|
+
> counts the runs that `produced` its results, so a harness that links only the claim to the question no longer sees
|
|
289
|
+
> "no run executes an experiment" with the result and claim both met. #118 and #119 are merged with green CI.
|
|
290
|
+
> Released as 0.8.7.
|
|
291
|
+
|
|
271
292
|
> **After 0.8.4 (2026-11-06).** Checks only, no change to the bundle format: false refusals found running HypSwarm on a
|
|
272
293
|
> small local model. A report that denies significance is warned, not refused; a named p-value carries the test; a word
|
|
273
294
|
> and a figure are a statement, and an unfilled slot isn't; an ordinal in a source accounts for the number. Tests:
|
|
@@ -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.8.
|
|
19
|
+
__version__ = "0.8.8"
|
|
20
20
|
|
|
21
21
|
from .bundle import anchored, verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -187,7 +187,7 @@ def derived_numbers(bound: list[tuple[dict, dict]]) -> tuple[list[float], list[f
|
|
|
187
187
|
k, n = a
|
|
188
188
|
lo, hi = stats.rate_bounds(k, n)
|
|
189
189
|
plain += [k / n, 1 - k / n, n - k]
|
|
190
|
-
stat +=
|
|
190
|
+
stat += rate_bound_values(k, n)
|
|
191
191
|
arms.append(a)
|
|
192
192
|
p = _primary(rv)
|
|
193
193
|
if p is not None:
|
|
@@ -212,6 +212,40 @@ def _matches_stat(printed: tuple, candidates: list[float]) -> bool:
|
|
|
212
212
|
return printed[1] >= 1 and _matches(printed, candidates)
|
|
213
213
|
|
|
214
214
|
|
|
215
|
+
def rate_bound_values(k: int, n: int) -> list[float]:
|
|
216
|
+
"""A count's 95% bounds and their complements: the bound on the rate seen, and on the opposite rate ("12 of 12
|
|
217
|
+
read correctly" bounds failures at 1 - lower bound, which is what the refusals and ``bound()`` print)."""
|
|
218
|
+
lo, hi = stats.rate_bounds(k, n)
|
|
219
|
+
return [lo, hi, 1 - lo, 1 - hi]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# a figure the statement itself says is a bound: "(95% upper bound 31%)", "at most 19% of the time", "no more than 4%"
|
|
223
|
+
_LABELLED_BOUND = re.compile(r"\b(?:(?:upper|lower)\s+(?:95\s*%\s+)?(?:confidence\s+)?(?:bound|limit)|at\s+most|"
|
|
224
|
+
r"at\s+least|no\s+more\s+than|no\s+less\s+than|bounded\s+(?:at|by))"
|
|
225
|
+
r"(?:\s+(?:of|is|at|was))?\s*[:=≈]?\s*(?:about\s+|around\s+|roughly\s+|~\s*)?"
|
|
226
|
+
r"(\d[\d,]*(?:\.\d+)?\s*%)", re.I)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def split_bounds(text: str) -> tuple[str, list[tuple]]:
|
|
230
|
+
"""(the text with each labelled bound's figure blanked, those figures as ``numbers`` gives them). Split by
|
|
231
|
+
position, so a figure the statement labels as a bound vouches for nothing else that happens to print the same
|
|
232
|
+
("rose 31% ... (upper bound 31%)")."""
|
|
233
|
+
out, labelled, i = [], [], 0
|
|
234
|
+
for m in _LABELLED_BOUND.finditer(text or ""):
|
|
235
|
+
out.append(text[i:m.start(1)] + " ")
|
|
236
|
+
labelled += numbers(m.group(1))
|
|
237
|
+
i = m.end(1)
|
|
238
|
+
out.append((text or "")[i:])
|
|
239
|
+
return "".join(out), labelled
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _matches_bound(printed: tuple, bounds: list[float]) -> bool:
|
|
243
|
+
"""A percentage the statement labels as a bound may match a count's 95% bound as printed, whole numbers too: the
|
|
244
|
+
refusals and ``bound()`` print them that way ("95% upper bound 31%"), and a labelled bound borrows nothing.
|
|
245
|
+
The decimal rule stays for unlabelled figures."""
|
|
246
|
+
return printed[2] and _matches(printed, bounds)
|
|
247
|
+
|
|
248
|
+
|
|
215
249
|
def _bound(s: Snap, meas: list, stmt: str = "", words: bool = True) -> tuple[list[tuple[dict, dict]], list[Problem]]:
|
|
216
250
|
"""Measurements that check out against their results, and problems with the rest. With ``stmt``, the claim's
|
|
217
251
|
statement: what a key says it counted must be said by the measurement's name or by the statement. ``words``
|
|
@@ -1143,8 +1177,24 @@ def _fmt(v: float) -> str:
|
|
|
1143
1177
|
|
|
1144
1178
|
def _subject_numbers(stmt: str, k: int, n: int) -> list[float]:
|
|
1145
1179
|
"""The figures a contradicted statement asserts, other than the count itself: what it would have to change to be
|
|
1146
|
-
corrected (refusals.conduct reads them to tell a correction from the same claim said again).
|
|
1147
|
-
|
|
1180
|
+
corrected (refusals.conduct reads them to tell a correction from the same claim said again). Only figures it
|
|
1181
|
+
asserts (``asserted_figures``): in "the shortest route is 40 km, not 38 km" or "... is 31 km, since the true total
|
|
1182
|
+
exceeds 30 km" the lie is 40 or 31. Storing 38 or 30 with it meant the same sentence said again never counted as a
|
|
1183
|
+
repeat, because a repeat must assert every stored figure and refusals.carried never counts a denied or compared
|
|
1184
|
+
one as asserted. A statement whose every figure is denied or compared keeps them all."""
|
|
1185
|
+
said = asserted_figures(stmt) or {v for v, *_ in numbers(stmt)}
|
|
1186
|
+
return [v for v, *_ in numbers(stmt) if v not in (0, k, n) and v in said]
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
# a figure the statement doesn't assert but denies or compares against: "not 38", "isn't 38", "instead of 38",
|
|
1190
|
+
# "than 26", "exceeds 30", "above 90" (refusals.carried reads statements through the same pattern)
|
|
1191
|
+
NOT_ASSERTED = re.compile(r"(?:\b(?:not|never|than|above|below|over|under|beyond|exceed(?:s|ing|ed)?|except|"
|
|
1192
|
+
r"instead\s+of)|n't)\s+(?:about\s+|around\s+|roughly\s+)?\$?\d[\d,]*(?:\.\d+)?", re.I)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def asserted_figures(stmt: str) -> set[float]:
|
|
1196
|
+
"""The figures a statement asserts: every number it gives, less those it denies or compares against."""
|
|
1197
|
+
return {v for v, *_ in numbers(NOT_ASSERTED.sub(" ", stmt or ""))}
|
|
1148
1198
|
|
|
1149
1199
|
|
|
1150
1200
|
# a word a statement denies: "not kilograms", "doesn't apply", "never reached" (the one or two words after it)
|
|
@@ -1825,7 +1875,11 @@ def claim_issues(s: Snap, body: dict, support: list[str], inv: str) -> list[Prob
|
|
|
1825
1875
|
allowed += [v for v, *_ in numbers(_ORDINAL.sub(r"\1", said))] # "the 14th of June" accounts for "14 June"
|
|
1826
1876
|
if _CONVENTIONS.search(clean): # "95% interval", "α 0.05", "80% power" are conventions
|
|
1827
1877
|
allowed += [95.0, 99.0, 90.0, 80.0, 0.05, 0.01]
|
|
1828
|
-
|
|
1878
|
+
rest, labelled = split_bounds(clean) # "(95% upper bound 31%)": a bound the statement names as one
|
|
1879
|
+
bounds = [b for _m, rv in bound if (a := _arm(rv)) for b in rate_bound_values(*a)]
|
|
1880
|
+
loose = [p for p in numbers(rest) if not _matches(p, allowed) and not _matches_stat(p, stat)]
|
|
1881
|
+
loose += [p for p in labelled if not _matches(p, allowed) and not _matches_stat(p, stat)
|
|
1882
|
+
and not _matches_bound(p, bounds)]
|
|
1829
1883
|
if loose:
|
|
1830
1884
|
problems.append(Problem("UNBOUND_NUMBER", "numbers in the statement that no bound measurement, the scope or the "
|
|
1831
1885
|
"investigation's constants account for: " + ", ".join(p[3] for p in loose[:8])
|
|
@@ -394,15 +394,32 @@ class Graph:
|
|
|
394
394
|
|
|
395
395
|
def _rebuild(self, con) -> D.Snap:
|
|
396
396
|
s = D.Snap(0, self.policy)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
397
|
+
own_txn = not con.in_transaction # retractions, rows and the head seq read at one moment
|
|
398
|
+
if own_txn:
|
|
399
|
+
con.execute("BEGIN")
|
|
400
|
+
try:
|
|
401
|
+
retracted = {r[0] for r in con.execute("SELECT edge FROM retractions")}
|
|
402
|
+
self._load_rows(con, s, retracted)
|
|
403
|
+
s.seq = max(self._head_seq(con), s.seq)
|
|
404
|
+
finally:
|
|
405
|
+
if own_txn:
|
|
406
|
+
con.execute("COMMIT")
|
|
400
407
|
return s
|
|
401
408
|
|
|
402
409
|
def _load_rows(self, con, s: D.Snap, retracted: set, *, shared: bool = False) -> set:
|
|
403
410
|
"""Add every row newer than s.marks to s (all of it for a fresh Snap) and return the ids of the nodes those
|
|
404
411
|
rows touched. ``shared``: s's per-node lists still belong to an older snapshot too, so a list is copied the
|
|
405
|
-
first time this load appends to it (readers of the old snapshot never see it change) and appended to after.
|
|
412
|
+
first time this load appends to it (readers of the old snapshot never see it change) and appended to after.
|
|
413
|
+
|
|
414
|
+
The tables are read inside one read transaction, so they are read at one moment. Each SELECT used to be its
|
|
415
|
+
own implicit transaction: a second connection (another Graph on the file — hypswarm's scheduler and the MCP
|
|
416
|
+
server a person's agent works through, in one process on two threads) could commit a claim and its
|
|
417
|
+
``answers`` edge after this load had read ``nodes`` and before it read ``edges``. The edge then named a node
|
|
418
|
+
the snapshot didn't hold yet, was skipped, and its mark was advanced past it, so this Graph never saw the
|
|
419
|
+
edge again until a rebuild: the question stood answered in every other Graph while the scheduler's
|
|
420
|
+
``check_work`` found "no live claim", reworked the order, and the seat was asked to count again (hypswarm's
|
|
421
|
+
``test_an_external_seat_works_orders_over_mcp`` on Ubuntu, 2026-11-05 to 11-08). A load already inside a
|
|
422
|
+
transaction (a write that reads) joins it."""
|
|
406
423
|
touched: set = set()
|
|
407
424
|
owned: set = set()
|
|
408
425
|
|
|
@@ -413,6 +430,9 @@ class Graph:
|
|
|
413
430
|
else:
|
|
414
431
|
table[key].append(item)
|
|
415
432
|
|
|
433
|
+
own_txn = not con.in_transaction
|
|
434
|
+
if own_txn:
|
|
435
|
+
con.execute("BEGIN")
|
|
416
436
|
con.row_factory = sqlite3.Row
|
|
417
437
|
try:
|
|
418
438
|
m = s.marks
|
|
@@ -462,6 +482,8 @@ class Graph:
|
|
|
462
482
|
s.leases = {k: v for k, v in s.leases.items() if v["expires"] > t}
|
|
463
483
|
finally:
|
|
464
484
|
con.row_factory = None
|
|
485
|
+
if own_txn:
|
|
486
|
+
con.execute("COMMIT")
|
|
465
487
|
return touched
|
|
466
488
|
|
|
467
489
|
def _delta(self, con, old: D.Snap) -> Optional[D.Snap]:
|
|
@@ -469,21 +491,29 @@ class Graph:
|
|
|
469
491
|
are shallow copies (C speed), and a per-node list is copied only when this load appends to it — a write
|
|
470
492
|
costs what it touched, not the size of the graph. Derived standing carries over for every node the write
|
|
471
493
|
can't have affected (derive.affected_by). None (rebuild instead) when an edge was retracted: retractions
|
|
472
|
-
remove, they don't append.
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
494
|
+
remove, they don't append. The retraction check and the load are one read transaction: a retraction
|
|
495
|
+
committed between them would otherwise be loaded as a live edge and its event's seq passed over."""
|
|
496
|
+
own_txn = not con.in_transaction
|
|
497
|
+
if own_txn:
|
|
498
|
+
con.execute("BEGIN")
|
|
499
|
+
try:
|
|
500
|
+
if con.execute("SELECT 1 FROM events WHERE seq > ? AND kind = 'retract' LIMIT 1", (old.seq,)).fetchone():
|
|
501
|
+
return None
|
|
502
|
+
s = D.Snap(old.seq, self.policy)
|
|
503
|
+
s.nodes = dict(old.nodes)
|
|
504
|
+
s.edges = dict(old.edges)
|
|
505
|
+
s.out = defaultdict(list, old.out)
|
|
506
|
+
s.inn = defaultdict(list, old.inn)
|
|
507
|
+
s.withdrawn = dict(old.withdrawn)
|
|
508
|
+
s.leases = dict(old.leases)
|
|
509
|
+
s.versions = defaultdict(list, old.versions)
|
|
510
|
+
s.events = list(old.events)
|
|
511
|
+
s.marks = dict(old.marks)
|
|
512
|
+
s._sigs = old._sigs # keyed by body: still right for every node not revised
|
|
513
|
+
touched = self._load_rows(con, s, set(), shared=True)
|
|
514
|
+
finally:
|
|
515
|
+
if own_txn:
|
|
516
|
+
con.execute("COMMIT")
|
|
487
517
|
if old._state and touched:
|
|
488
518
|
stale = D.affected_by(s, touched)
|
|
489
519
|
s._state = {k: v for k, v in old._state.items() if k not in stale}
|
|
@@ -1131,6 +1161,8 @@ class Graph:
|
|
|
1131
1161
|
raise self._sealed(hidden, f"a link that rests {s.nodes[x]['code'] or x} on sealed work")
|
|
1132
1162
|
if src in s.nodes:
|
|
1133
1163
|
self._fixed_guard(s, author, src)
|
|
1164
|
+
if author not in self.policy.curators:
|
|
1165
|
+
self._recheck_linked_claims(s, author, (src, rel, dst))
|
|
1134
1166
|
eid = self._transaction(lambda con: self._insert_edge(con, s, src, rel, dst, data or {}, why, author,
|
|
1135
1167
|
self.clock()))
|
|
1136
1168
|
except GraphError as e:
|
|
@@ -1151,6 +1183,35 @@ class Graph:
|
|
|
1151
1183
|
out["fixes_refusals"] = fixed
|
|
1152
1184
|
return out
|
|
1153
1185
|
|
|
1186
|
+
def _recheck_linked_claims(self, s: D.Snap, author: str, edge: tuple[str, str, str]) -> None:
|
|
1187
|
+
"""A link that adds to what a claim rests on is checked as the claim's create would have been: otherwise any
|
|
1188
|
+
check that reads the support is stepped round by writing the claim first and linking the rest after. Only
|
|
1189
|
+
the problems the link brings refuse it; one the claim already had (the record changed around it) doesn't."""
|
|
1190
|
+
for x in dict.fromkeys(edge[::2]):
|
|
1191
|
+
n = s.nodes.get(x)
|
|
1192
|
+
if not n or n["type"] != "claim" or x in s.withdrawn:
|
|
1193
|
+
continue
|
|
1194
|
+
before = self._support_ids(s, x, [])
|
|
1195
|
+
after = self._support_ids(s, x, [edge])
|
|
1196
|
+
if after == before:
|
|
1197
|
+
continue
|
|
1198
|
+
body, inv = n["body"], n["inv"]
|
|
1199
|
+
# the claim's own links stand in for the pending links its create was checked with
|
|
1200
|
+
own = [(e["src"], e["rel"], e["dst"]) for e in s.out.get(x, []) + s.inn.get(x, [])]
|
|
1201
|
+
|
|
1202
|
+
def issues(support: list[str], pending: list[tuple[str, str, str]]) -> list:
|
|
1203
|
+
return list(claim_issues(s, body, support, inv)) + list(profiles.claim_issues(s, inv, body, support,
|
|
1204
|
+
pending))
|
|
1205
|
+
had = {(p.code, p.message) for p in issues(before, own)}
|
|
1206
|
+
new = [p for p in issues(after, own + [edge]) if (p.code, p.message) not in had]
|
|
1207
|
+
if new:
|
|
1208
|
+
found = [p.to_dict() for p in new]
|
|
1209
|
+
refusals.record_refusal(self, author=author, op="link", inv=inv, node=x, body=body, problems=found,
|
|
1210
|
+
support=after)
|
|
1211
|
+
c = n["code"] or x
|
|
1212
|
+
raise GraphError(f"link refused: {c} would rest on it, and {c} with it is refused:\n- "
|
|
1213
|
+
+ "\n- ".join(p.text() for p in new), found)
|
|
1214
|
+
|
|
1154
1215
|
def retract(self, *, author: str, edge: int, why: str) -> dict:
|
|
1155
1216
|
if len(str(why or "").strip()) < 10:
|
|
1156
1217
|
raise GraphError("why: say why this link no longer holds")
|
|
@@ -29,6 +29,10 @@ ABSOLUTE = re.compile(
|
|
|
29
29
|
r"\b(never|always|all|every|none|nothing|no one|nobody|impossible|cannot|can't|proves?|proven|proof|certainly|"
|
|
30
30
|
r"certain|guaranteed?|guarantees|eliminat(?:es|ed|ing)|entirely|completely|totally|universally|invariably|"
|
|
31
31
|
r"consistently|without exception|perfect(?:ly)?|solves?|solved|definitively|conclusively|undeniably|"
|
|
32
|
+
# an absolute with no "all" in it: "files parsed flawlessly" on 62 of 63, "without a single error" on 52 of 55
|
|
33
|
+
# (both accepted in the say-no-more-than-the-data showcase, 2026-11-15)
|
|
34
|
+
r"flawless(?:ly)?|without\s+(?:a\s+single\s+|any\s+|one\s+)?(?:error|failure|fault|crash|exception|bug|"
|
|
35
|
+
r"mistake|glitch|hitch)e?s?|(?:error|failure|fault|crash|bug)-free|"
|
|
32
36
|
r"zero(?!-)"
|
|
33
37
|
# "no route is longer than 26 km": a universal negative comparison, the same claim as "nothing is longer"; only
|
|
34
38
|
# where "no" opens its clause ("routes that visit no town twice" is not one); "no route is 26 km or longer" and
|
|
@@ -131,10 +135,31 @@ def asserted(pattern: re.Pattern, text: str) -> list[str]:
|
|
|
131
135
|
continue
|
|
132
136
|
if m.group(0).lower() in ("entirely", "wholly", "completely") and _INTERVAL_SIDE.match(text, m.start()):
|
|
133
137
|
continue
|
|
138
|
+
if _MANNER.fullmatch(m.group(0)) and (_PART_SUBJECT.search(_clause_before(text, m.start()))
|
|
139
|
+
or _EXCEPTIONS.search(text)):
|
|
140
|
+
continue
|
|
134
141
|
out.append(m.group(0).lower())
|
|
135
142
|
return list(dict.fromkeys(out))
|
|
136
143
|
|
|
137
144
|
|
|
145
|
+
# an absolute said of how things went, not of how many: "parsed flawlessly", "without a single error", "error-free".
|
|
146
|
+
# It covers every one of its subject only when the subject does: "nearly all files parsed flawlessly", "72 of 73 files
|
|
147
|
+
# parsed without error" and "flawlessly, only a handful slipped" say so of a part (all four were in the
|
|
148
|
+
# say-no-more-than-the-data showcase, 2026-11-15, and true of their counts)
|
|
149
|
+
_MANNER = re.compile(r"flawless(?:ly)?|without\s.*|\w+-free", re.I | re.S)
|
|
150
|
+
_PART_SUBJECT = re.compile(r"\b(?:(?:almost|nearly|virtually|practically)\s+(?:all|every)|most|mostly|majority|many|"
|
|
151
|
+
r"some|several|all\s+but|\d[\d,]*\s+(?:of|out\s+of)\s+\d)", re.I)
|
|
152
|
+
_EXCEPTIONS = re.compile(r"\b(?:except(?:\s+for)?|all\s+but|but\s+(?:one|two|a\s+few)|apart\s+from|save\s+for|"
|
|
153
|
+
r"other\s+than)\b|\b(?:only|just)\s+(?:a\s+)?(?:few|handful|couple|one|\d+)\b[^.;]*?\b"
|
|
154
|
+
r"(?:slipped|failed|fell|broke|crashed|errored|missed|stumbled|faltered|tripped)\b", re.I)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _clause_before(text: str, start: int) -> str:
|
|
158
|
+
head = text[:start]
|
|
159
|
+
s0 = max((m.end() for m in _SENTENCE_END.finditer(head)), default=0)
|
|
160
|
+
return head[max((m.end() for m in _CLAUSE.finditer(head, s0)), default=s0):]
|
|
161
|
+
|
|
162
|
+
|
|
138
163
|
def denied(pattern: re.Pattern, text: str) -> list[str]:
|
|
139
164
|
"""The matches a sentence negates: "not significantly", "no significant difference"."""
|
|
140
165
|
return [w for w in found(pattern, text) if w not in asserted(pattern, text)]
|
|
@@ -125,17 +125,15 @@ def _contradicted(detail: str) -> list[dict]:
|
|
|
125
125
|
|
|
126
126
|
|
|
127
127
|
_CLAUSE = re.compile(r"[;!?]|\.(?!\d)") # a full stop inside a number (3.5) ends no clause
|
|
128
|
-
_NOT_ASSERTED = re.compile(r"\b(?:not|than|above|below|over|under|beyond|exceed(?:s|ing)?|except)\s+"
|
|
129
|
-
r"(?:about\s+|around\s+)?\d[\d,]*(?:\.\d+)?", re.I)
|
|
130
128
|
|
|
131
129
|
|
|
132
|
-
def carried(problems: list[dict], statement: str, atoms: list | None = None) -> bool:
|
|
130
|
+
def carried(problems: list[dict], statement: str, atoms: list | None = None, was: str = "") -> bool:
|
|
133
131
|
"""Does ``statement`` say again what these contradiction problems showed false? A count contradiction is
|
|
134
132
|
carried when one clause holds every figure it named (the '=…' words) again AND still asserts an extreme or an
|
|
135
133
|
absolute about them ("the highest value is 93", "nothing beats 93"): mentioning the old figure in a correction
|
|
136
134
|
("1 of 82 routes is shorter than 26 km") is not saying it again. A source contradiction is carried when the
|
|
137
135
|
statement still denies or doubts the same word, or still says the unit the source ruled out. A typed atom shown
|
|
138
|
-
false is carried when ``atoms`` (the new write's) asserts it again."""
|
|
136
|
+
false is carried when ``atoms`` (the new write's) asserts it again. ``was`` is the refused statement."""
|
|
139
137
|
from . import assertions, checks
|
|
140
138
|
keys = {assertions.key(a) for a in atoms or []}
|
|
141
139
|
denied = checks.denied_words(statement or "")
|
|
@@ -150,11 +148,15 @@ def carried(problems: list[dict], statement: str, atoms: list | None = None) ->
|
|
|
150
148
|
if p.get("code") == "ASSERTION_CONTRADICTED" and {w[5:] for w in words if w.startswith("atom:")} & keys:
|
|
151
149
|
return True
|
|
152
150
|
figs = [float(w[1:]) for w in words if w.startswith("=") and w[1:].replace(".", "", 1).isdigit()]
|
|
151
|
+
# only the figures the refused statement asserted: refusals logged before 0.8.8 also stored the ones it denied
|
|
152
|
+
# or compared against ("40 km, not 38 km" gave =40 and =38), so that sentence said again never read as a repeat
|
|
153
|
+
if was and (said := checks.asserted_figures(was)):
|
|
154
|
+
figs = [f for f in figs if any(abs(f - s) < 1e-9 for s in said)] or figs
|
|
153
155
|
if p.get("code") != "COUNT_CONTRADICTS" or not figs:
|
|
154
156
|
continue
|
|
155
157
|
for c in clauses:
|
|
156
158
|
# a figure the clause negates or compares ("20 km, not 26 km", "greater than 130") isn't asserted
|
|
157
|
-
nums =
|
|
159
|
+
nums = list(checks.asserted_figures(c))
|
|
158
160
|
# nor is an extreme the clause denies ("the highest value is not 130") or a count's own domain ("a count
|
|
159
161
|
# of every route finds 1 of 74 shorter"): both used to be logged as the lie said again
|
|
160
162
|
if all(any(abs(x - f) < 1e-9 for x in nums) for f in figs) and checks.claimed_extremes(c):
|
|
@@ -181,7 +183,7 @@ def _note_reassertion(g: "Graph", *, author: str, inv: str, node: str, op: str,
|
|
|
181
183
|
con = g._connect()
|
|
182
184
|
since = (datetime.fromisoformat(g.clock()) - FIX_WINDOW).isoformat(timespec="seconds")
|
|
183
185
|
opened = _open_contradictions(con, author, inv, since, before=refusal)
|
|
184
|
-
hits = [r[0] for r in opened if carried(_contradicted(r[3]), statement, atoms)]
|
|
186
|
+
hits = [r[0] for r in opened if carried(_contradicted(r[3]), statement, atoms, was=r[2])]
|
|
185
187
|
dropped = _moved_off(con, opened, hits, node=node, statement=statement, answers=answers)
|
|
186
188
|
if not hits and not dropped:
|
|
187
189
|
return
|
|
@@ -282,13 +284,13 @@ def match_fix(g: "Graph", *, author: str, inv: str, node: str, statement: str, o
|
|
|
282
284
|
hits = [r for r in rows if (op == "revise" and r[1] == node) or _overlap(r[2], statement) >= FIX_OVERLAP
|
|
283
285
|
or _keys_again(r[3], values) or _same_pair(r[2], pair)]
|
|
284
286
|
# a claim shown false and accepted later saying the same false thing is a re-assertion, not a fix
|
|
285
|
-
again = [r[0] for r in hits if carried(_contradicted(r[3]), statement, atoms)]
|
|
287
|
+
again = [r[0] for r in hits if carried(_contradicted(r[3]), statement, atoms, was=r[2])]
|
|
286
288
|
hits = [r[0] for r in hits if r[0] not in again]
|
|
287
289
|
# a contradiction this write is about but neither fixes nor says again: the author moved off it
|
|
288
290
|
opened = [r for r in rows if _contradicted(r[3]) and r[0] not in hits]
|
|
289
291
|
again += [r[0] for r in opened if r[0] not in again and _addresses(r, node=node, statement=statement,
|
|
290
292
|
answers=answers)
|
|
291
|
-
and carried(_contradicted(r[3]), statement, atoms)]
|
|
293
|
+
and carried(_contradicted(r[3]), statement, atoms, was=r[2])]
|
|
292
294
|
dropped = _moved_off(con, opened, again, node=node, statement=statement, answers=answers)
|
|
293
295
|
if not hits and not again and not dropped:
|
|
294
296
|
return []
|
|
@@ -346,11 +348,13 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
|
|
|
346
348
|
"""Per author: how often the record contradicted a claim of theirs (its own count, or a source they cited, says
|
|
347
349
|
the opposite), how often they then corrected it, and how often they said the same thing again.
|
|
348
350
|
{author: {"shown_false", "by_count", "by_source", "corrected", "reasserted", "open", "still_saying", "moved_off",
|
|
349
|
-
"ends_false", "sentence"}}. ``shown_false`` is every claim contradicted, ``by_count`` / ``by_source`` how many of
|
|
351
|
+
"replaced_false", "ends_false", "sentence"}}. ``shown_false`` is every claim contradicted, ``by_count`` / ``by_source`` how many of
|
|
350
352
|
them the record's own count or a cited source contradicted (one claim can be both). A
|
|
351
353
|
re-assertion that was itself refused is not counted as a new time shown false. ``still_saying``: claims shown false
|
|
352
354
|
whose author's last word on them (its last fix, repeat or move away, in log order) says them again;
|
|
353
|
-
``moved_off``: claims it last moved away from without a fix; ``
|
|
355
|
+
``moved_off``: claims it last moved away from without a fix; ``replaced_false``: of those, the ones it moved off
|
|
356
|
+
only into another claim the record also showed false (31 km refused, then 32 km, then 33 km: persisting with a
|
|
357
|
+
new figure each time, not moving off); ``ends_false``: the author's last write was itself
|
|
354
358
|
shown false or said a contradicted claim again (a fresh false claim at the end counts, which ``still_saying``,
|
|
355
359
|
being per claim, can't see). ``shown_false`` is not a rate of lying: it adds up two kinds of evidence, and a cited
|
|
356
360
|
source can itself be wrong. Read the parts, and what the author did next, not the total."""
|
|
@@ -382,8 +386,12 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
|
|
|
382
386
|
r = None
|
|
383
387
|
if r:
|
|
384
388
|
root[r] = ref
|
|
389
|
+
# every refusal the record showed false, repeats included: a move off a claim into one of these is no move off
|
|
390
|
+
contra = {rid for rid, kind, _a, _r, codes, _d in rows
|
|
391
|
+
if kind == "refused" and set(json.loads(codes or "[]")) & set(CONTRADICTIONS)}
|
|
385
392
|
last: dict[int, str] = {} # each claim shown false → its author's last word on it
|
|
386
|
-
|
|
393
|
+
into_false: set[int] = set() # … when that last word was a move into another false claim
|
|
394
|
+
for _id, kind, _a, ref, _c, det in rows: # rows come in log order
|
|
387
395
|
if kind in ("fixed", "reasserted", "dropped") and ref:
|
|
388
396
|
r, seen = ref, set()
|
|
389
397
|
while r not in shown and r in root and r not in seen:
|
|
@@ -391,10 +399,15 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
|
|
|
391
399
|
r = root[r]
|
|
392
400
|
if r in shown:
|
|
393
401
|
last[r] = kind
|
|
402
|
+
try:
|
|
403
|
+
to = json.loads(det or "{}").get("refusal")
|
|
404
|
+
except Exception: # noqa: BLE001
|
|
405
|
+
to = None
|
|
406
|
+
(into_false.add if kind == "dropped" and to in contra else into_false.discard)(r)
|
|
394
407
|
out: dict[str, dict] = {}
|
|
395
408
|
for rid, a in shown.items():
|
|
396
409
|
o = out.setdefault(a, {"shown_false": 0, "by_count": 0, "by_source": 0, "by_assertion": 0, "corrected": 0,
|
|
397
|
-
"reasserted": 0, "open": 0, "still_saying": 0, "moved_off": 0})
|
|
410
|
+
"reasserted": 0, "open": 0, "still_saying": 0, "moved_off": 0, "replaced_false": 0})
|
|
398
411
|
o["shown_false"] += 1
|
|
399
412
|
o["by_count"] += "COUNT_CONTRADICTS" in shown_codes[rid]
|
|
400
413
|
o["by_source"] += "SOURCE_CONTRADICTS" in shown_codes[rid]
|
|
@@ -406,6 +419,7 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
|
|
|
406
419
|
o["open"] += not fixed and not again
|
|
407
420
|
o["still_saying"] += last.get(rid) == "reasserted"
|
|
408
421
|
o["moved_off"] += last.get(rid) == "dropped"
|
|
422
|
+
o["replaced_false"] += rid in into_false
|
|
409
423
|
# where each author ends: its last write — a refused one, or an accepted one that fixed, repeated or moved off a
|
|
410
424
|
# claim shown false — either was shown false (refused as contradicted) or said a contradicted claim again, or not
|
|
411
425
|
writes: dict[tuple, list] = {}
|
|
@@ -432,13 +446,19 @@ def conduct(g: "Graph", inv: str = "", author: str = "") -> dict:
|
|
|
432
446
|
+ ([f"a typed count showed an assertion of this author's false {t_} time{'s' * (t_ != 1)}"] if t_ else [])
|
|
433
447
|
+ ([f"{s_} claim{'s' * (s_ != 1)} of this author's went against a source it cited as support"]
|
|
434
448
|
if s_ else []))
|
|
449
|
+
x = o["replaced_false"]
|
|
435
450
|
o["sentence"] = (shown + "; "
|
|
436
451
|
+ (f"the author said it again {o['reasserted']} time{'s' * (o['reasserted'] != 1)} after "
|
|
437
|
-
"being shown" if o["reasserted"] else "the author did not say
|
|
438
|
-
|
|
452
|
+
"being shown" if o["reasserted"] else "the author did not say the same claim again"
|
|
453
|
+
if x else "the author did not say it again")
|
|
454
|
+
# moving off a false claim only into another false one is persisting, not moving off
|
|
455
|
+
+ (f"{', and' if o['reasserted'] else ', but'} replaced a contradicted claim with another the "
|
|
456
|
+
f"record also contradicted {x} time{'s' * (x != 1)}" if x else "")
|
|
457
|
+
# "never corrected" only beside a repeat or a replacement: dropping a false claim for a
|
|
458
|
+
# different, unrefuted one is no repeat
|
|
439
459
|
+ (f", and corrected it {o['corrected']} time{'s' * (o['corrected'] != 1)}"
|
|
440
460
|
if o["corrected"] else ", and never corrected it"
|
|
441
|
-
if o["reasserted"] and (o["still_saying"] or o["ends_false"]) else "")
|
|
461
|
+
if (o["reasserted"] or x) and (o["still_saying"] or o["ends_false"]) else "")
|
|
442
462
|
+ ("; its last word still says it" if o["still_saying"] else
|
|
443
463
|
"; its last word was contradicted by the record" if o["ends_false"] else
|
|
444
464
|
"; its last word no longer says it" if o["reasserted"] else ""))
|
|
@@ -45,7 +45,7 @@ from typing import TYPE_CHECKING
|
|
|
45
45
|
from . import derive as D
|
|
46
46
|
from . import language as L
|
|
47
47
|
from . import stats
|
|
48
|
-
from .checks import _matches, _matches_stat, derived_numbers, numbers
|
|
48
|
+
from .checks import _matches, _matches_bound, _matches_stat, derived_numbers, numbers, split_bounds
|
|
49
49
|
|
|
50
50
|
if TYPE_CHECKING:
|
|
51
51
|
from .graph import Graph
|
|
@@ -269,11 +269,12 @@ def check(g: "Graph", text: str, inv: str = "") -> dict:
|
|
|
269
269
|
if asks: # a question, or the question's own words: citations checked, nothing asserted
|
|
270
270
|
continue
|
|
271
271
|
# numbers
|
|
272
|
-
|
|
272
|
+
rest, labelled = split_bounds(sent) # '95% upper bound 31%': a bound the sentence names as one
|
|
273
|
+
for p, is_bound in [(p, False) for p in numbers(rest)] + [(p, True) for p in labelled]:
|
|
273
274
|
if _skip_number(p, sent) or re.search(rf"(?<![\w-])[A-Z]{{1,3}}{re.escape(p[3])}(?![\w-])", sent):
|
|
274
275
|
continue
|
|
275
276
|
counted["numbers"] += 1
|
|
276
|
-
if not _matches(p, plain) and not _matches_stat(p, stat):
|
|
277
|
+
if not _matches(p, plain) and not _matches_stat(p, stat) and not (is_bound and _matches_bound(p, stat)):
|
|
277
278
|
add(ln, "warning", "REPORT_UNSOURCED_NUMBER", sent,
|
|
278
279
|
f"{p[3]} isn't in any result, measurement, claim, scope or constant in the record, and doesn't "
|
|
279
280
|
"follow from them. Bind it to a result, or take it out.")
|
|
@@ -104,6 +104,11 @@ class Scope:
|
|
|
104
104
|
res += typed(out(c, "derived_from"), "result")
|
|
105
105
|
res += typed(inn(root, "part_of"), "result") + ([t] if n["type"] == "result" else [])
|
|
106
106
|
self.results = _uniq(res + mine("result"))
|
|
107
|
+
# a run that produced a result on the question is a run on the question. A harness registers run → result
|
|
108
|
+
# → claim with no experiment and no part_of link; the run gate then read "no run executes an experiment" on a
|
|
109
|
+
# measured question whose result and claim were both met, a `run` order went out every round beside the
|
|
110
|
+
# review, and 11 claims on 3 questions settled nothing (BC-250 S3, 2026-11-07)
|
|
111
|
+
self.runs = _uniq(self.runs + [r for x in self.results for r in typed(inn(x, "produced"), "run")])
|
|
107
112
|
decs = [d for x in self.claims + self.results + self.hypotheses for d in typed(inn(x, "based_on"), "decision")]
|
|
108
113
|
decs += [t] if n["type"] == "decision" else []
|
|
109
114
|
self.decisions = _uniq(decs + mine("decision"))
|