hypgraph 0.7.9__tar.gz → 0.7.11__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.7.9 → hypgraph-0.7.11}/CHANGELOG.md +44 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/PKG-INFO +1 -1
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/spec.md +5 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/blind.py +45 -15
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/coord.py +15 -6
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/graph.py +81 -2
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/mcp.py +10 -6
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/schema.py +4 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/hypgraph.css +2 -1
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_blind.py +46 -0
- hypgraph-0.7.11/tests/test_blind_oracles.py +191 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/.gitignore +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/AGENTS.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/CITATION.cff +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/DISCLAIMER.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/LICENSE +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/NOTICE +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/README.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/PLAN.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/site-plan.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/pyproject.toml +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/cli.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/references.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/server.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_coord.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_d10.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_engine.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_references.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_server_hosts.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.11 — 2026-10-30: a sealed checker can't learn the answer by asking
|
|
4
|
+
|
|
5
|
+
An adversarial test on a local HypMesh planted a 5-digit reading and 12-digit tags in a lead's sealed nodes and
|
|
6
|
+
attacked every route as the blind checker. Nothing leaked directly, but the checker could tell a right guess from a
|
|
7
|
+
wrong one, and 60 requests recovered the reading exactly. Fixed:
|
|
8
|
+
|
|
9
|
+
- **Errors no longer mark a right guess.** Answers scrubbed only the numbers the sealed material carries, so a route
|
|
10
|
+
that echoes its input (`resolve`, `node`, `why`, `order`, `check_work`, a refused write) answered "no node
|
|
11
|
+
'[sealed]'" for a right guess and "no node '48220'" for a wrong one. While a reader is sealed, every number in an
|
|
12
|
+
error is now blanked, secret or not; the MCP seat does the same.
|
|
13
|
+
- **A write is answered with the view after it.** The checker's own new numbers were scrubbed with the view from
|
|
14
|
+
before the write, so a result carrying the right reading came back "[sealed]".
|
|
15
|
+
- **No check reads sealed material for a sealed writer.** A claim bound to the sealed result was refused with an
|
|
16
|
+
unbound number for a wrong guess and passed that check for the right one, and the auto-bind looked the guess up
|
|
17
|
+
in the sealed results without any id given. A sealed writer may now link to a sealed node only to say its own work
|
|
18
|
+
supports, challenges or tests a hypothesis; binding to, resting on, reviewing, revising or ordering work on sealed
|
|
19
|
+
nodes is refused with one message before any check runs, and a seat holding a blind order issues no orders.
|
|
20
|
+
`Graph.sealed_from(hide)` lets a host apply its reader's view to writes, so writing under another name doesn't
|
|
21
|
+
escape the seal.
|
|
22
|
+
- **The leak check at issue skips what is sealed from the issuer**, which would otherwise answer the issuer's guesses.
|
|
23
|
+
|
|
24
|
+
`tests/test_blind_oracles.py` asks every request with the right and a wrong guess on fresh records and requires the
|
|
25
|
+
same answer; 14 of its 23 tests fail on 0.7.10.
|
|
26
|
+
|
|
27
|
+
## 0.7.10 — 2026-10-30: fixes from the first live blind run
|
|
28
|
+
|
|
29
|
+
Claude led a question on a HypMesh and issued a blind order; Codex took the order through its seat and counted the
|
|
30
|
+
primes below 1,000,000 whose digits sum to 23 with its own sieve. The record marked its result written blind and
|
|
31
|
+
agreeing blind with Claude's, and nothing sealed reached Codex. Four fixes from that run and a phone-width check:
|
|
32
|
+
|
|
33
|
+
- **Investigations are part of the problem statement a blind checker reads.** Only the order's own investigation
|
|
34
|
+
and the curators' were. A lead whose framing lived in a second investigation had it sealed, the numbers that
|
|
35
|
+
define the problem (1,000,000; 23) read as secrets, and an honest blind order was refused. Questions and
|
|
36
|
+
investigations say what is being found out; don't put answers in them.
|
|
37
|
+
- **`my_work` without an investigation lists your orders in every investigation.** It asked "which
|
|
38
|
+
investigation?" when a record held two, and the seat's first call failed.
|
|
39
|
+
- **`produced_by`, `generated_by`, `output_of`, `executed_by` and `tested_by` are read as the relation they
|
|
40
|
+
invert.** The node types fix the direction, so the link is turned round. Codex's first result was refused for
|
|
41
|
+
`produced_by`.
|
|
42
|
+
- **On a phone, every tab of the embedded app can be reached.** The embed's `overflow: visible` beat the narrow
|
|
43
|
+
rule that makes the nav a scrolling strip. On hypdmz.com at 375 px, four of the eight tabs (Evidence, Work
|
|
44
|
+
orders, Audit log, Report) sat past the edge with no way to scroll to them. The nav now scrolls, and the embed
|
|
45
|
+
clips sideways, so the closed drawer can't widen a host page that doesn't clip it.
|
|
46
|
+
|
|
3
47
|
## 0.7.9 — 2026-10-30: blind work orders
|
|
4
48
|
|
|
5
49
|
- **A checker can measure without seeing the answer it checks.** `issue_work(..., blind=True)` (MCP `issue_work`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.11
|
|
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/
|
|
@@ -162,6 +162,11 @@ Implementations must derive these, never store them:
|
|
|
162
162
|
- Nobody officially reviews their own node.
|
|
163
163
|
- Writes into an `archived` or `published` investigation are refused (reserved authors excepted).
|
|
164
164
|
- Only the importer may set a write's time. Everyone else's comes from the writer's clock.
|
|
165
|
+
- A writer sealed from nodes (it holds an open blind work order, or a host passes its reader's view with
|
|
166
|
+
`Graph.sealed_from`) may link to a sealed node only to say its own work `supports`, `challenges` or `tests` a
|
|
167
|
+
hypothesis. It may not bind a measurement to a sealed result, rest a claim on sealed work, review or revise a sealed
|
|
168
|
+
node, or issue a work order on sealed work, and a seat holding a blind order issues no orders. These are refused
|
|
169
|
+
with one message, before any check reads the sealed material, so that no answer depends on what is sealed.
|
|
165
170
|
|
|
166
171
|
## 7. Claim checks
|
|
167
172
|
|
|
@@ -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.
|
|
19
|
+
__version__ = "0.7.11"
|
|
20
20
|
|
|
21
21
|
from .bundle import verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
Blind work orders: a checker measures without seeing the answer it is checking.
|
|
3
3
|
|
|
4
4
|
A work order issued with ``blind=True`` seals the record from its assignee until the order is returned. While it is
|
|
5
|
-
open, the assignee reads the problem statement (the
|
|
6
|
-
|
|
5
|
+
open, the assignee reads the problem statement (the investigations and questions: what is being found out, never what
|
|
6
|
+
was found) and its own order; everything else other authors wrote — hypotheses and their predictions, results,
|
|
7
7
|
claims, reviews, runs and their code, sources, other orders, other teams' attempts — is sealed: shown as a placeholder,
|
|
8
8
|
left out of search, its numbers scrubbed wherever else they appear.
|
|
9
9
|
|
|
@@ -21,6 +21,11 @@ The record enforces what it can see:
|
|
|
21
21
|
wrote a node. ``hypgraph serve`` and the Python API read the whole record: a file is only as blind as whoever
|
|
22
22
|
holds it.
|
|
23
23
|
|
|
24
|
+
A sealed reader can't learn what is sealed by asking either: an error that echoes its request has every number
|
|
25
|
+
blanked (not only the secret ones, which would mark a right guess), a write is answered with the view after it, and a
|
|
26
|
+
sealed writer's writes may not rest on sealed nodes, so no check reads them on its behalf (``graph.sealed_from``).
|
|
27
|
+
``tests/test_blind_oracles.py`` asks every request with a right and a wrong guess and requires the same answer.
|
|
28
|
+
|
|
24
29
|
It cannot see reads. A seat that read the record before its order was issued has already seen what is now sealed;
|
|
25
30
|
give a checker its order first.
|
|
26
31
|
"""
|
|
@@ -39,7 +44,10 @@ _NUM = re.compile(r"(?<![\w.,])-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?![\w])")
|
|
|
39
44
|
READ_ROUTES = frozenset({"investigations", "orders", "order", "resolve", "subgraph", "node", "why", "impact",
|
|
40
45
|
"neighborhood", "search", "events", "verify", "profiles", "profile", "references"})
|
|
41
46
|
POST_ROUTES = frozenset({"check_work"})
|
|
42
|
-
|
|
47
|
+
# The problem statement every reader keeps. An investigation says what is being found out; a lead whose framing
|
|
48
|
+
# lived in a second investigation had it sealed, and the numbers that define the problem (1,000,000; 23) then read
|
|
49
|
+
# as secrets, so an honest blind order was refused (first live run, 2026-10-30). Don't put answers in them.
|
|
50
|
+
VISIBLE_TYPES = frozenset({"question", "investigation"})
|
|
43
51
|
# A blind order asks for a measurement. A review or a report has to read what it judges.
|
|
44
52
|
BLIND_PREDICATES = frozenset({"run_registered", "result_exists", "claim_made", "within_budget"})
|
|
45
53
|
SEALED = "sealed"
|
|
@@ -228,10 +236,8 @@ def _derived(s: D.Snap, x: str, limit: int = 60) -> set[str]:
|
|
|
228
236
|
|
|
229
237
|
# ── what is sealed, and from whom ──────────────────────────────────
|
|
230
238
|
def public(s: D.Snap, n: dict, w: Window) -> bool:
|
|
231
|
-
"""The problem statement every reader keeps: questions
|
|
232
|
-
|
|
233
|
-
return (n["type"] in VISIBLE_TYPES
|
|
234
|
-
or (n["type"] == "investigation" and (n["created_by"] in s.policy.curators or n["id"] == w.inv)))
|
|
239
|
+
"""The problem statement every reader keeps: questions and investigations (the order chain is kept apart)."""
|
|
240
|
+
return n["type"] in VISIBLE_TYPES
|
|
235
241
|
|
|
236
242
|
|
|
237
243
|
def scope(s: D.Snap, w: Window) -> set[str]:
|
|
@@ -274,6 +280,26 @@ class View:
|
|
|
274
280
|
return [self.scrub(v) for v in x]
|
|
275
281
|
return x
|
|
276
282
|
|
|
283
|
+
def blank(self, x: Any) -> Any:
|
|
284
|
+
"""Every significant number replaced, secret or not: for answers that may echo what the reader sent (errors,
|
|
285
|
+
refusals). Scrubbing only the secret ones there would mark a right guess as "[sealed]" and leave a wrong one
|
|
286
|
+
as written — an oracle (an adversarial run found six routes that did, 2026-10-30)."""
|
|
287
|
+
if not self:
|
|
288
|
+
return x
|
|
289
|
+
if isinstance(x, str):
|
|
290
|
+
return _NUM.sub(lambda m: "[number]" if _significant(canon(m.group(0))) else m.group(0), x)
|
|
291
|
+
if isinstance(x, dict):
|
|
292
|
+
return {k: self.blank(v) for k, v in x.items()}
|
|
293
|
+
if isinstance(x, list):
|
|
294
|
+
return [self.blank(v) for v in x]
|
|
295
|
+
return x
|
|
296
|
+
|
|
297
|
+
def answer(self, status: int, out: Any) -> Any:
|
|
298
|
+
"""A response as this reader gets it: redacted, and with every number blanked when it is an error."""
|
|
299
|
+
if not self:
|
|
300
|
+
return out
|
|
301
|
+
return self.blank(self._walk(out)) if status >= 400 else self.apply(out)
|
|
302
|
+
|
|
277
303
|
def node(self, d: dict) -> dict:
|
|
278
304
|
"""A sealed node as a reader sees it: what it is and where, not what it says."""
|
|
279
305
|
code = self.hide.get(d.get("id"), "")
|
|
@@ -377,26 +403,30 @@ def dispatch(g, method: str, path: str, *, query: dict | None = None, body: dict
|
|
|
377
403
|
parts = [p for p in path.strip("/").split("/") if p]
|
|
378
404
|
head, arg = (parts[0] if parts else ""), (parts[1] if len(parts) > 1 else "")
|
|
379
405
|
if method == "POST" and head == "write":
|
|
380
|
-
|
|
381
|
-
|
|
406
|
+
# the write is checked as this reader: nothing it may not see is read on its behalf (graph.sealed_from); its
|
|
407
|
+
# answer is redacted with the view after the write, in which its own new numbers are no longer secret
|
|
408
|
+
with g.sealed_from(v.hide):
|
|
409
|
+
status, out = plain(g, method, path, query=query, body=body, **kw)
|
|
410
|
+
return status, (view_for(g.snapshot(), keep) or v).answer(status, out)
|
|
382
411
|
if not v.allows(method, head):
|
|
383
|
-
return 409, {"detail": v.message(f"/{head}"), SEALED: v.because()}
|
|
412
|
+
return 409, v.blank({"detail": v.message(f"/{head}"), SEALED: v.because()})
|
|
384
413
|
if head in ("order", "node", "why", "impact", "neighborhood") and arg:
|
|
385
414
|
try:
|
|
386
415
|
nid = g.resolve(arg)
|
|
387
416
|
except Exception:
|
|
388
417
|
nid = arg
|
|
389
418
|
if head == "order" and nid in v.hide:
|
|
390
|
-
return 409, {"detail": v.message(f"order {arg}"), SEALED: v.because()}
|
|
419
|
+
return 409, v.blank({"detail": v.message(f"order {arg}"), SEALED: v.because()})
|
|
391
420
|
status, out = plain(g, method, path, query=query, body=body, **kw)
|
|
392
421
|
if head == "search" and status == 200 and isinstance(out, dict) and isinstance(out.get("results"), list):
|
|
393
422
|
out = out | {"results": v.search(out["results"])}
|
|
394
|
-
return status, v.
|
|
423
|
+
return status, v.answer(status, out)
|
|
395
424
|
|
|
396
425
|
|
|
397
|
-
def leaks(s: D.Snap, w: Window, texts: list[str]) -> dict[str, list[str]]:
|
|
398
|
-
"""Numbers an order's own text quotes that only the material it would seal carries: number → codes holding it.
|
|
399
|
-
|
|
426
|
+
def leaks(s: D.Snap, w: Window, texts: list[str], skip: dict | set | None = None) -> dict[str, list[str]]:
|
|
427
|
+
"""Numbers an order's own text quotes that only the material it would seal carries: number → codes holding it.
|
|
428
|
+
``skip``: material sealed from the issuer, left out (comparing against it would answer the issuer's guesses)."""
|
|
429
|
+
hide = scope(s, w) - set(skip or ())
|
|
400
430
|
visible: set[str] = set()
|
|
401
431
|
for i, n in s.nodes.items():
|
|
402
432
|
if i not in hide:
|
|
@@ -371,6 +371,11 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
|
|
|
371
371
|
raise GraphError(str(e).strip("'\"") + " — a target is a code the record has (Q1, H2, X1, CL3; find lists "
|
|
372
372
|
"them) or the investigation's id; add a hypothesis or experiment first if you need a new "
|
|
373
373
|
"one") from None
|
|
374
|
+
from . import blind as B
|
|
375
|
+
own = B.view_for_author(s, author)
|
|
376
|
+
if own: # a seat holding a blind order measures; it issues orders once it has returned it
|
|
377
|
+
raise g._sealed(own.hide, "a work order")
|
|
378
|
+
hidden = g._hidden(s, author)
|
|
374
379
|
if D.out_of_play(s, t):
|
|
375
380
|
raise GraphError(f"{_code(s, t)} is {D.lifecycle(s, t)}{' and fallen' if D.fallen(s, t) else ''} — "
|
|
376
381
|
"nothing to work on")
|
|
@@ -386,9 +391,6 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
|
|
|
386
391
|
preds = [norm_pred(p) for p in (done_when or profiles.default_done(s, inv, s.nodes[t]["type"])
|
|
387
392
|
or DEFAULT_DONE.get(s.nodes[t]["type"], [{"p": "result_exists"}]))]
|
|
388
393
|
_known_predicates(preds)
|
|
389
|
-
if blind:
|
|
390
|
-
_blind_ok(s, inv=inv, author=author, assignee=assignee, target=t, preds=preds, prediction=prediction,
|
|
391
|
-
texts=[objective, method, note, role])
|
|
392
394
|
if premises is None:
|
|
393
395
|
prem = [a for a, _r, _d in D.ancestors(s, t, limit=40) if s.nodes[a]["type"] not in ("artifact", "task")]
|
|
394
396
|
if s.nodes[t]["type"] == "experiment":
|
|
@@ -396,6 +398,11 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
|
|
|
396
398
|
else:
|
|
397
399
|
prem = prem_ids
|
|
398
400
|
prem = [p for p in dict.fromkeys(prem) if p in s.nodes and p != t][:30]
|
|
401
|
+
if hidden and (t in hidden or any(p in hidden for p in prem)): # its text would quote sealed work
|
|
402
|
+
raise g._sealed(hidden, "a work order on sealed work")
|
|
403
|
+
if blind:
|
|
404
|
+
_blind_ok(s, inv=inv, author=author, assignee=assignee, target=t, preds=preds, prediction=prediction,
|
|
405
|
+
texts=[objective, method, note, role], skip=hidden)
|
|
399
406
|
fields = {"objective": objective, "assignee": assignee, "role": role, "done_when": preds,
|
|
400
407
|
"prediction": prediction, "method": method, "budget": budget or {}, "note": note,
|
|
401
408
|
"title": objective[:300]}
|
|
@@ -410,8 +417,10 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
|
|
|
410
417
|
|
|
411
418
|
|
|
412
419
|
def _blind_ok(s: D.Snap, *, inv: str, author: str, assignee: str, target: str, preds: list[dict], prediction: str,
|
|
413
|
-
texts: list[str]) -> None:
|
|
414
|
-
"""A blind order's checks at issue: what the record can see of whether the assignee will really work blind.
|
|
420
|
+
texts: list[str], skip: dict | None = None) -> None:
|
|
421
|
+
"""A blind order's checks at issue: what the record can see of whether the assignee will really work blind.
|
|
422
|
+
``skip``: what is sealed from the issuer itself, left out of the leak check (comparing against it would tell the
|
|
423
|
+
issuer whether a number it guessed is in there)."""
|
|
415
424
|
from .graph import GraphError
|
|
416
425
|
from . import blind as B
|
|
417
426
|
if prediction.strip():
|
|
@@ -431,7 +440,7 @@ def _blind_ok(s: D.Snap, *, inv: str, author: str, assignee: str, target: str, p
|
|
|
431
440
|
raise GraphError(f"{assignee} has already written in this record ({', '.join(wrote)}), so it has read what a "
|
|
432
441
|
"blind order would seal. Give the blind order to a seat that hasn't")
|
|
433
442
|
w = B.Window("", [], assignee, inv, s.seq, None, sealing=True, fresh=True)
|
|
434
|
-
found = B.leaks(s, w, [x for x in texts if x])
|
|
443
|
+
found = B.leaks(s, w, [x for x in texts if x], skip=skip)
|
|
435
444
|
if found:
|
|
436
445
|
said = "; ".join(f"{k} (in {', '.join(v[:3])})" for k, v in sorted(found.items())[:6])
|
|
437
446
|
raise GraphError(f"the order quotes what it would seal: {said}. Say what to measure and how, not what it "
|
|
@@ -170,6 +170,25 @@ def clean_body(ntype: str, body: dict, *, partial_ok: bool = False) -> dict:
|
|
|
170
170
|
|
|
171
171
|
class _Writer(threading.local):
|
|
172
172
|
task = ""
|
|
173
|
+
hide: Optional[dict] = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class sealed_from:
|
|
177
|
+
"""``with g.sealed_from(hide):`` — the writes inside come from a reader these nodes are sealed from (a host's blind
|
|
178
|
+
view: node id → the order that seals it, ``blind.view_for(...).hide``). They may not rest on those nodes, and no
|
|
179
|
+
check reads them on the writer's behalf: a check that did would answer a right guess differently from a wrong one.
|
|
180
|
+
An author holding a blind order is sealed this way without it; a host passes its reader's view so that writing
|
|
181
|
+
under another name doesn't escape the seal."""
|
|
182
|
+
def __init__(self, graph: "Graph", hide: dict | None):
|
|
183
|
+
self.graph = graph
|
|
184
|
+
self.hide = dict(hide or {})
|
|
185
|
+
|
|
186
|
+
def __enter__(self):
|
|
187
|
+
self.prev, self.graph._writer.hide = self.graph._writer.hide, self.hide
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def __exit__(self, *exc):
|
|
191
|
+
self.graph._writer.hide = self.prev
|
|
173
192
|
|
|
174
193
|
|
|
175
194
|
class writing_for:
|
|
@@ -259,6 +278,42 @@ class Graph:
|
|
|
259
278
|
def writing_for(self, task: str | None) -> writing_for:
|
|
260
279
|
return writing_for(self, task)
|
|
261
280
|
|
|
281
|
+
def sealed_from(self, hide: dict | None) -> sealed_from:
|
|
282
|
+
return sealed_from(self, hide)
|
|
283
|
+
|
|
284
|
+
def _hidden(self, s: D.Snap, author: str) -> dict:
|
|
285
|
+
"""What is sealed from this write's author: its own open blind orders' scope, and the host's view if given."""
|
|
286
|
+
from . import blind
|
|
287
|
+
out = dict(self._writer.hide or {})
|
|
288
|
+
for i, c in blind.view_for_author(s, author).hide.items():
|
|
289
|
+
out.setdefault(i, c)
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _sealed(hidden: dict, what: str) -> GraphError:
|
|
294
|
+
"""The one refusal a sealed writer gets, the same whatever it guessed."""
|
|
295
|
+
codes = ", ".join(sorted({c for c in hidden.values() if c})) or "a blind work order"
|
|
296
|
+
return GraphError(f"{what}: that work is sealed from you while {codes} is open, so nothing you write may rest on "
|
|
297
|
+
"it or be checked against it. Record your own measurement, return the order (return_work), "
|
|
298
|
+
"and build on the rest once it opens.",
|
|
299
|
+
[{"code": "SEALED", "message": what}])
|
|
300
|
+
|
|
301
|
+
def _sealed_measurements(self, s: D.Snap, hidden: dict, body: dict, inv: str) -> None:
|
|
302
|
+
for m in body.get("measurements") or []:
|
|
303
|
+
ref = str(m.get("result") or "") if isinstance(m, dict) else ""
|
|
304
|
+
hits = [ref] if ref in s.nodes else [i for i, n in s.nodes.items()
|
|
305
|
+
if ref and n["code"].lower() == ref.lower() and n["inv"] == inv]
|
|
306
|
+
if any(h in hidden for h in hits):
|
|
307
|
+
raise self._sealed(hidden, f"a measurement bound to {ref}")
|
|
308
|
+
|
|
309
|
+
def _sealed_links(self, s: D.Snap, hidden: dict, links: list[tuple[str, str, str]]) -> None:
|
|
310
|
+
"""A sealed writer's links may reach a sealed node only to say its own work supports, challenges or tests a
|
|
311
|
+
hypothesis — what a blind order targets; a claim, result or anything else would be read on its behalf."""
|
|
312
|
+
for a, rel, b in links:
|
|
313
|
+
for x in (a, b):
|
|
314
|
+
if x in hidden and not (s.nodes[x]["type"] == "hypothesis" and rel in ("supports", "challenges", "tests")):
|
|
315
|
+
raise self._sealed(hidden, f"a {rel} link to {s.nodes[x]['code'] or x}")
|
|
316
|
+
|
|
262
317
|
def author(self, name: str) -> "Author":
|
|
263
318
|
"""A handle that writes as ``name`` — convenient in notebooks and scripts."""
|
|
264
319
|
return Author(self, name)
|
|
@@ -707,12 +762,17 @@ class Graph:
|
|
|
707
762
|
pending = [(a, rel, b) for a, rel, b, _f in oriented]
|
|
708
763
|
flipped = [f"{s.nodes[a].get('code') or a} -{rel}-> {s.nodes[b].get('code') or b}"
|
|
709
764
|
for a, rel, b, f in oriented if f]
|
|
765
|
+
hidden = self._hidden(s, author)
|
|
766
|
+
if hidden:
|
|
767
|
+
self._sealed_links(s, hidden, pending)
|
|
710
768
|
for a, rel, b in pending:
|
|
711
769
|
self._check_link(s, a, rel, b)
|
|
712
770
|
auto_notes: list[str] = []
|
|
713
771
|
if type == "claim":
|
|
714
772
|
body = self._bind_measurements(s, body, inv)
|
|
715
773
|
staged["body"] = body
|
|
774
|
+
if hidden:
|
|
775
|
+
self._sealed_measurements(s, hidden, body, inv)
|
|
716
776
|
# a measurement names the result its number comes from: that result supports the claim, so the
|
|
717
777
|
# standing counts it as a line of support without a separate link from the writer
|
|
718
778
|
for m in body.get("measurements") or []:
|
|
@@ -729,7 +789,9 @@ class Graph:
|
|
|
729
789
|
if type == "claim" and author not in P.curators:
|
|
730
790
|
problems = claim_issues(s, body, self._support_ids(s, nid, pending), inv)
|
|
731
791
|
loose = [w for p in problems if p.code == "UNBOUND_NUMBER" for w in p.words]
|
|
732
|
-
|
|
792
|
+
# repair before refusing: bind what the record holds — not for a sealed writer, whose numbers
|
|
793
|
+
# would be looked up in what is sealed from it
|
|
794
|
+
if loose and not hidden:
|
|
733
795
|
body, extra, auto_notes = self._auto_bind(s, body, inv, who,
|
|
734
796
|
self._support_ids(s, nid, pending), loose)
|
|
735
797
|
if auto_notes:
|
|
@@ -976,15 +1038,22 @@ class Graph:
|
|
|
976
1038
|
raise
|
|
977
1039
|
extra: list[str] = []
|
|
978
1040
|
auto_notes: list[str] = []
|
|
1041
|
+
hidden = self._hidden(s, author)
|
|
1042
|
+
if hidden and node in hidden:
|
|
1043
|
+
raise self._sealed(hidden, f"a revision of {n['code'] or node}")
|
|
979
1044
|
if n["type"] == "claim":
|
|
980
1045
|
body = self._bind_measurements(s, body, n["inv"])
|
|
1046
|
+
if hidden:
|
|
1047
|
+
self._sealed_measurements(s, hidden, body, n["inv"])
|
|
1048
|
+
if set(self._support_ids(s, node, [])) & set(hidden):
|
|
1049
|
+
raise self._sealed(hidden, f"a revision of {n['code'] or node}, which rests on sealed work")
|
|
981
1050
|
if n["type"] == "run" and body.get("kind") != n["body"].get("kind") and author != P.operator:
|
|
982
1051
|
raise GraphError("a run's kind (lab / agent / external) is where its record came from; it doesn't "
|
|
983
1052
|
"change")
|
|
984
1053
|
if n["type"] == "claim" and author not in P.curators:
|
|
985
1054
|
problems = claim_issues(s, body, self._support_ids(s, node, []), n["inv"])
|
|
986
1055
|
loose = [w for p in problems if p.code == "UNBOUND_NUMBER" for w in p.words]
|
|
987
|
-
if loose:
|
|
1056
|
+
if loose and not hidden:
|
|
988
1057
|
body, extra, auto_notes = self._auto_bind(s, body, n["inv"], n["created_by"],
|
|
989
1058
|
self._support_ids(s, node, []), loose)
|
|
990
1059
|
if auto_notes:
|
|
@@ -1032,6 +1101,13 @@ class Graph:
|
|
|
1032
1101
|
if translated:
|
|
1033
1102
|
data = {**(data or {}), "read_as": given} # what the author wrote
|
|
1034
1103
|
src, rel, dst, flipped = self._orient(s, src, rel, dst)
|
|
1104
|
+
hidden = self._hidden(s, author)
|
|
1105
|
+
if hidden:
|
|
1106
|
+
self._sealed_links(s, hidden, [(src, rel, dst)])
|
|
1107
|
+
for x in (src, dst): # a claim of its own may not come to rest on sealed work
|
|
1108
|
+
if x in s.nodes and s.nodes[x]["type"] == "claim" and \
|
|
1109
|
+
set(self._support_ids(s, x, [(src, rel, dst)])) & set(hidden):
|
|
1110
|
+
raise self._sealed(hidden, f"a link that rests {s.nodes[x]['code'] or x} on sealed work")
|
|
1035
1111
|
if src in s.nodes:
|
|
1036
1112
|
self._fixed_guard(s, author, src)
|
|
1037
1113
|
eid = self._transaction(lambda con: self._insert_edge(con, s, src, rel, dst, data or {}, why, author,
|
|
@@ -1146,6 +1222,9 @@ class Graph:
|
|
|
1146
1222
|
raise GraphError(f"no node {target}")
|
|
1147
1223
|
if n["type"] not in RELS["reviews"][1]:
|
|
1148
1224
|
raise GraphError(f"a {n['type']} isn't reviewed")
|
|
1225
|
+
hidden = self._hidden(s, author)
|
|
1226
|
+
if hidden and target in hidden:
|
|
1227
|
+
raise self._sealed(hidden, f"a review of {n['code'] or target}")
|
|
1149
1228
|
official = author in D.board(s, target) and verdict != "comment"
|
|
1150
1229
|
if official and author == n["created_by"] and author not in P.curators:
|
|
1151
1230
|
who = ", ".join(sorted(D.board(s, target) - P.curators - {author})[:6]) or "another reviewer"
|
|
@@ -186,10 +186,12 @@ def build(graph: Graph | str, author: Optional[str] = None, files_root: str | No
|
|
|
186
186
|
try:
|
|
187
187
|
out = fn(*a, **kw)
|
|
188
188
|
except ToolError as e:
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
after = sealed() or v
|
|
190
|
+
if after: # every number blanked: scrubbing only the secret ones would mark a right guess
|
|
191
|
+
raise ToolError(after.blank(str(e))) from None
|
|
191
192
|
raise
|
|
192
|
-
|
|
193
|
+
after = sealed() # after a write its own new numbers are no longer secret
|
|
194
|
+
return after.scrub(out) if after and isinstance(out, str) else out
|
|
193
195
|
return mcp.tool(wrapped)
|
|
194
196
|
|
|
195
197
|
def fail(e: Exception):
|
|
@@ -551,10 +553,12 @@ def build(graph: Graph | str, author: Optional[str] = None, files_root: str | No
|
|
|
551
553
|
|
|
552
554
|
@tool
|
|
553
555
|
def my_work(investigation: str = "") -> str:
|
|
554
|
-
"""Your open work orders, each with its full text
|
|
555
|
-
it waits for acceptance, and comes back as a new order if it is
|
|
556
|
+
"""Your open work orders, each with its full text, in every investigation unless you name one. An order you
|
|
557
|
+
have returned is no longer yours to work: it waits for acceptance, and comes back as a new order if it is
|
|
558
|
+
reworked."""
|
|
556
559
|
try:
|
|
557
|
-
|
|
560
|
+
invs = [g.resolve(investigation)] if investigation else [i["id"] for i in g.investigations()]
|
|
561
|
+
allmine = [o for inv in invs for o in g.orders(inv) if o["assignee"] == me]
|
|
558
562
|
except (GraphError, KeyError) as e:
|
|
559
563
|
fail(e)
|
|
560
564
|
mine = [o for o in allmine if o["status"] in ("open", "overdue")]
|
|
@@ -148,6 +148,10 @@ REL_SYNONYMS: dict[str, str] = {
|
|
|
148
148
|
"correct": "corrects", "corrected": "corrects", "require": "requires", "required": "requires",
|
|
149
149
|
"depends_on": "requires", "part": "part_of", "belongs_to": "part_of", "motivated": "motivated_by",
|
|
150
150
|
"rests": "rests_on", "works": "works_on", "blocked": "blocked_by", "settle": "settles", "settled": "settles",
|
|
151
|
+
# inverses whose node types fix the direction (a result can't produce a run): turned round by _orient. Not
|
|
152
|
+
# supported_by / challenged_by: a claim can sit at either end of those.
|
|
153
|
+
"produced_by": "produced", "generated_by": "produced", "output_of": "produced", "executed_by": "executes",
|
|
154
|
+
"tested_by": "tests",
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
SYNONYMS: dict[tuple[str, str], dict[str, str]] = {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
.hg.hg-embed .hg-app { height: auto; grid-template-rows: 52px auto; }
|
|
93
93
|
.hg.hg-embed .hg-main, .hg.hg-embed .hg-nav { overflow: visible; }
|
|
94
94
|
.hg.hg-embed .feed { max-height: none; overflow: visible; }
|
|
95
|
-
.hg.hg-embed { overflow: visible; }
|
|
95
|
+
.hg.hg-embed { overflow-x: clip; overflow-y: visible; } /* the closed drawer sits off to the right: never widen the page */
|
|
96
96
|
.hg .kpi { padding: 12px 14px; } .hg .kpi .k { color: var(--ink2); font-size: 12px; }
|
|
97
97
|
.hg .kpi .v { font-size: 22px; font-weight: 650; letter-spacing: -.02em; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
|
98
98
|
.hg .kpi .s { color: var(--ink3); font-size: 11.5px; margin-top: 2px; }
|
|
@@ -247,6 +247,7 @@
|
|
|
247
247
|
.hg .hg-nav button { width: auto; flex: none; white-space: nowrap; padding: 6px 10px; }
|
|
248
248
|
.hg .hg-nav .n { margin-left: 4px; } .hg .hg-nav .inv .s { display: none; }
|
|
249
249
|
.hg .hg-main { padding: 16px 16px 32px; }
|
|
250
|
+
.hg.hg-embed .hg-nav { overflow-x: auto; overflow-y: hidden; } /* beats the embed's overflow: visible: every tab reachable */
|
|
250
251
|
.hg .trace .tr { grid-template-columns: minmax(90px, 40%) minmax(0, 1fr) 48px; }
|
|
251
252
|
}
|
|
252
253
|
@media (prefers-reduced-motion: reduce) { .hg *, .hg *::before { animation: none !important; transition: none !important; } }
|
|
@@ -166,3 +166,49 @@ def test_mcp_seat_holding_a_blind_order(env, tmp_path):
|
|
|
166
166
|
ov = text(await c.call_tool("overview", {"investigation": inv}))
|
|
167
167
|
assert "Primes below" in ov
|
|
168
168
|
asyncio.run(go())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── from the first live run (Claude leading, Codex checking on HypMesh, 2026-10-30) ──
|
|
172
|
+
def test_a_framing_investigation_is_the_problem_statement_and_my_work_finds_the_order():
|
|
173
|
+
fastmcp = pytest.importorskip("fastmcp")
|
|
174
|
+
from hypgraph.mcp import build
|
|
175
|
+
g = Graph(clock=Clock())
|
|
176
|
+
lead = g.author("lead")
|
|
177
|
+
frame = lead.investigation("The question", "Count the primes below 1,000,000 whose digits sum to 23.")["id"]
|
|
178
|
+
inv = lead.investigation("Lead's attempt", "Count them, and have the count checked blind.")["id"]
|
|
179
|
+
h = lead.add("hypothesis", inv, statement="There are 5503 primes below 1,000,000 whose digits sum to 23.",
|
|
180
|
+
predictions=["an independent count finds 5503"])["id"]
|
|
181
|
+
o = g.issue_work(author="lead", inv=inv, target=h, assignee="checker", blind=True,
|
|
182
|
+
objective="Count the primes below 1,000,000 whose digits sum to 23, with your own code.")
|
|
183
|
+
v = B.view_for_author(g.snapshot(), "checker")
|
|
184
|
+
assert frame not in v.hide and h in v.hide and "5503" in v.secrets and "23" not in v.secrets
|
|
185
|
+
with pytest.raises(GraphError, match="quotes what it would seal: 5503"):
|
|
186
|
+
h2 = lead.add("hypothesis", inv, statement="Most of them are below 900,000.",
|
|
187
|
+
predictions=["more than half fall below 900,000"])["id"]
|
|
188
|
+
g.issue_work(author="lead", inv=inv, target=h2, assignee="other", blind=True,
|
|
189
|
+
objective="Check that the count is 5503.")
|
|
190
|
+
|
|
191
|
+
async def go():
|
|
192
|
+
async with fastmcp.Client(build(g, author="checker")) as c:
|
|
193
|
+
r = await c.call_tool("my_work", {}) # no investigation named, two in the record
|
|
194
|
+
assert o["code"] in r.content[0].text and "BLIND" in r.content[0].text
|
|
195
|
+
asyncio.run(go())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_inverse_relation_words_are_turned_round(env):
|
|
199
|
+
g, inv, h1, rs1 = env
|
|
200
|
+
a = g.author("lead")
|
|
201
|
+
run = a.add("run", inv, kind="agent", name="second sieve", state="succeeded")["id"]
|
|
202
|
+
rs = a.add("result", inv, summary="The second sieve counts 1230 primes below 10,000; the largest is 9967.",
|
|
203
|
+
outcome="positive", values={"count": 1230, "largest": 9967},
|
|
204
|
+
links=[{"rel": "produced_by", "to": run}])
|
|
205
|
+
assert any(e["rel"] == "produced" and e["src"] == run for e in rs["in"])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_the_embedded_app_never_widens_its_page():
|
|
209
|
+
from pathlib import Path
|
|
210
|
+
import hypgraph.ui as ui
|
|
211
|
+
css = (Path(ui.__file__).parent / "hypgraph.css").read_text(encoding="utf-8")
|
|
212
|
+
assert ".hg.hg-embed { overflow-x: clip;" in css
|
|
213
|
+
narrow = css.split("@container hg (max-width: 760px)", 1)[1].split("\n}\n", 1)[0]
|
|
214
|
+
assert ".hg.hg-embed .hg-nav { overflow-x: auto;" in narrow
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""A sealed checker can't learn what is sealed by asking: every request it can make answers the same whether its guess
|
|
2
|
+
of a sealed value is right or wrong (an adversarial test on a live mesh, 2026-10-30, found six routes that marked a
|
|
3
|
+
right guess as "[sealed]", and claim checks that accepted a right guess and refused a wrong one).
|
|
4
|
+
|
|
5
|
+
Each case runs on its own fresh record twice, once with the sealed reading and once with a wrong one, through
|
|
6
|
+
``blind.dispatch`` as a host serves the checker (its own nodes kept), and the two answers must be identical once the
|
|
7
|
+
guess itself and fresh ids are blanked."""
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from hypgraph import Graph, Policy
|
|
14
|
+
from hypgraph import blind as B
|
|
15
|
+
|
|
16
|
+
R, WRONG = 48213, 48220
|
|
17
|
+
WORD = "quillotine"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Clock:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.t = datetime(2026, 1, 1, 9, 0, 0)
|
|
23
|
+
|
|
24
|
+
def __call__(self):
|
|
25
|
+
self.t += timedelta(seconds=1)
|
|
26
|
+
return self.t.isoformat(timespec="seconds")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fixture():
|
|
30
|
+
"""The lead read the instrument (R) and holds it in a hypothesis, a result and a bound claim; the checker has a
|
|
31
|
+
blind order and its own run."""
|
|
32
|
+
g = Graph(policy=Policy(reviewers={"critic"}), clock=Clock())
|
|
33
|
+
lead = g.author("lead")
|
|
34
|
+
inv = lead.investigation("Read the instrument", "Record the instrument's reading and have it checked blind.")["id"]
|
|
35
|
+
h1 = lead.add("hypothesis", inv, statement=f"The instrument reads {R}, codeword {WORD}.",
|
|
36
|
+
predictions=[f"A second reading gives {R}."])["id"]
|
|
37
|
+
run = lead.add("run", inv, kind="agent", name=f"read by {WORD}", state="succeeded")["id"]
|
|
38
|
+
rs1 = lead.add("result", inv, summary=f"The instrument read {R} ({WORD}).", outcome="positive",
|
|
39
|
+
values={"reading": R},
|
|
40
|
+
links=[{"rel": "produced", "from": run}, {"rel": "supports", "to": h1, "why": "the reading it predicts"}])["id"]
|
|
41
|
+
lead.add("claim", inv, statement=f"The instrument reads {R}.", kind="finding",
|
|
42
|
+
scope="One reading of the instrument, taken by the lead.",
|
|
43
|
+
measurements=[{"result": rs1, "key": "reading"}], links=[{"rel": "supports", "from": rs1, "why": "it measured it"}])
|
|
44
|
+
o = g.issue_work(author="lead", inv=inv, target="H1", assignee="checker", blind=True,
|
|
45
|
+
objective="Take your own reading of the instrument and record it as a result named reading.")
|
|
46
|
+
mine = g.author("checker").add("run", inv, kind="agent", name="my own reading", state="succeeded")["id"]
|
|
47
|
+
return g, {"inv": inv, "h1": h1, "rs1": rs1, "run": run, "order": o["order"], "mine": mine}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def own(g):
|
|
51
|
+
return lambda i, n: n["created_by"] == "checker"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ask(g, method, path, body=None, query=None):
|
|
55
|
+
return B.dispatch(g, method, path, query=query, body=body, keep=own(g), writable=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write(g, body):
|
|
59
|
+
return ask(g, "POST", "/write", {"author": "checker", **body})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# every request a checker might make with a guess in it: (name, fn(g, ids, x) -> (status, answer))
|
|
63
|
+
CASES = {
|
|
64
|
+
"resolve": lambda g, i, x: ask(g, "GET", "/resolve", query={"ref": str(x)}),
|
|
65
|
+
"node": lambda g, i, x: ask(g, "GET", f"/node/{x}"),
|
|
66
|
+
"why": lambda g, i, x: ask(g, "GET", f"/why/{x}"),
|
|
67
|
+
"order": lambda g, i, x: ask(g, "GET", f"/order/{x}"),
|
|
68
|
+
"search": lambda g, i, x: ask(g, "GET", "/search", query={"text": str(x)}),
|
|
69
|
+
"check_work text": lambda g, i, x: ask(g, "POST", "/check_work", {"order": i["order"], "text": f"I read {x}."}),
|
|
70
|
+
"check_work order": lambda g, i, x: ask(g, "POST", "/check_work", {"order": str(x)}),
|
|
71
|
+
"bad type": lambda g, i, x: write(g, {"op": "create", "type": f"t {x}", "inv": i["inv"], "fields": {}}),
|
|
72
|
+
"bad field": lambda g, i, x: write(g, {"op": "create", "type": "result", "inv": i["inv"],
|
|
73
|
+
"fields": {"summary": f"read {x}", "outcome": "positive", "zz": f"{x} x"}}),
|
|
74
|
+
"claim bound to the sealed result": lambda g, i, x: write(g, {
|
|
75
|
+
"op": "create", "type": "claim", "inv": i["inv"],
|
|
76
|
+
"fields": {"statement": f"The instrument reads {x}.", "scope": "My reading of the instrument, taken once.",
|
|
77
|
+
"kind": "finding", "measurements": [{"result": i["rs1"], "key": "reading"}]},
|
|
78
|
+
"links": [{"rel": "supports", "from": i["rs1"], "why": "the reading it measured"}]}),
|
|
79
|
+
"claim with an unbound number": lambda g, i, x: write(g, {
|
|
80
|
+
"op": "create", "type": "claim", "inv": i["inv"],
|
|
81
|
+
"fields": {"statement": f"The instrument reads {x}.", "scope": "My reading of the instrument, taken once.",
|
|
82
|
+
"kind": "finding"}}),
|
|
83
|
+
"claim by code": lambda g, i, x: write(g, {
|
|
84
|
+
"op": "create", "type": "claim", "inv": i["inv"],
|
|
85
|
+
"fields": {"statement": f"The instrument reads {x}.", "scope": "My reading of the instrument, taken once.",
|
|
86
|
+
"kind": "finding", "measurements": [{"result": "RS1", "key": "reading"}]}}),
|
|
87
|
+
"result supporting the target": lambda g, i, x: write(g, {
|
|
88
|
+
"op": "create", "type": "result", "inv": i["inv"],
|
|
89
|
+
"fields": {"summary": f"My reading is {x}.", "outcome": "positive", "values": {"reading": x}},
|
|
90
|
+
"links": [{"rel": "produced", "from": i["mine"]}, {"rel": "supports", "to": i["h1"], "why": "the reading it predicts"}]}),
|
|
91
|
+
"result challenging the target": lambda g, i, x: write(g, {
|
|
92
|
+
"op": "create", "type": "result", "inv": i["inv"],
|
|
93
|
+
"fields": {"summary": f"My reading is {x}.", "outcome": "positive", "values": {"reading": x}},
|
|
94
|
+
"links": [{"rel": "produced", "from": i["mine"]}, {"rel": "challenges", "to": i["h1"], "why": "a different reading"}]}),
|
|
95
|
+
"result replicating the sealed one": lambda g, i, x: write(g, {
|
|
96
|
+
"op": "create", "type": "result", "inv": i["inv"],
|
|
97
|
+
"fields": {"summary": f"My reading is {x}.", "outcome": "positive", "values": {"reading": x}},
|
|
98
|
+
"links": [{"rel": "produced", "from": i["mine"]}, {"rel": "replicates", "to": i["rs1"], "why": "same reading"}]}),
|
|
99
|
+
"review of the sealed result": lambda g, i, x: write(g, {
|
|
100
|
+
"op": "review", "target": i["rs1"], "verdict": "pass", "method": "re-read the instrument",
|
|
101
|
+
"notes": f"I read {x}, the same."}),
|
|
102
|
+
"review of the target": lambda g, i, x: write(g, {
|
|
103
|
+
"op": "review", "target": i["h1"], "verdict": "pass", "method": "re-read the instrument",
|
|
104
|
+
"notes": f"I read {x}, the same."}),
|
|
105
|
+
"blind order to another seat": lambda g, i, x: write(g, {
|
|
106
|
+
"op": "issue_work", "inv": i["inv"], "target": i["mine"], "assignee": "third", "blind": True,
|
|
107
|
+
"objective": f"Check that the instrument reads {x}, with a reading of your own."}),
|
|
108
|
+
"order on the target": lambda g, i, x: write(g, {
|
|
109
|
+
"op": "issue_work", "inv": i["inv"], "target": i["h1"], "assignee": "third",
|
|
110
|
+
"objective": f"Check that the instrument reads {x}, with a reading of your own."}),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_case(fn, x):
|
|
115
|
+
g, ids = fixture()
|
|
116
|
+
status, out = fn(g, ids, x)
|
|
117
|
+
# the checker's own new node as it then reads it, with its derived state (a right guess must not show there)
|
|
118
|
+
after = []
|
|
119
|
+
if status == 200 and isinstance(out, dict) and out.get("id") in g.snapshot().nodes:
|
|
120
|
+
after = ask(g, "GET", f"/node/{out['id']}")
|
|
121
|
+
return status, out, after
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def norm(x, guess):
|
|
125
|
+
t = repr(x)
|
|
126
|
+
t = t.replace(f"{guess:,}", "<X>").replace(str(guess), "<X>")
|
|
127
|
+
t = re.sub(r"\bn[0-9a-f]{12}\b", "<N>", t)
|
|
128
|
+
t = re.sub(r"\binv-[0-9a-f]{8}\b", "<INV>", t)
|
|
129
|
+
t = re.sub(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d", "<T>", t)
|
|
130
|
+
return t
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("name", list(CASES))
|
|
134
|
+
def test_a_right_guess_answers_like_a_wrong_one(name):
|
|
135
|
+
right, wrong = run_case(CASES[name], R), run_case(CASES[name], WRONG)
|
|
136
|
+
assert norm(right, R) == norm(wrong, WRONG), f"{name}: the answer tells a right guess from a wrong one"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_no_answer_carries_sealed_words():
|
|
140
|
+
for name, fn in CASES.items():
|
|
141
|
+
for x in (R, WRONG):
|
|
142
|
+
assert WORD not in repr(run_case(fn, x)), f"{name} quotes sealed text"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_a_sealed_checker_still_does_its_work():
|
|
146
|
+
"""The fixes refuse nothing the order needs: a result of its own that supports or tests the target."""
|
|
147
|
+
for name in ("result supporting the target", "result challenging the target"):
|
|
148
|
+
status, out, _ = run_case(CASES[name], WRONG)
|
|
149
|
+
assert status == 200, (name, out)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_host_view_seals_writes_under_another_name():
|
|
153
|
+
"""A host seals by reader (HypMesh: the agent token), and a token may write under several names. The write is
|
|
154
|
+
checked against the reader's view, so a name with no blind order of its own learns no more."""
|
|
155
|
+
def as_alias(x):
|
|
156
|
+
g, i = fixture()
|
|
157
|
+
return ask(g, "POST", "/write", {"author": "alias", "op": "create", "type": "claim", "inv": i["inv"],
|
|
158
|
+
"fields": {"statement": f"The instrument reads {x}.", "kind": "finding",
|
|
159
|
+
"scope": "My reading of the instrument, taken once.",
|
|
160
|
+
"measurements": [{"result": i["rs1"], "key": "reading"}]}})
|
|
161
|
+
right, wrong = as_alias(R), as_alias(WRONG)
|
|
162
|
+
assert right[0] == 400 and "sealed from you" in right[1]["detail"]
|
|
163
|
+
assert norm(right, R) == norm(wrong, WRONG)
|
|
164
|
+
g, i = fixture() # with no host view, plain hypgraph takes the name at its word
|
|
165
|
+
with pytest.raises(Exception, match="link the evidence|refused"):
|
|
166
|
+
g.create(author="alias", type="claim", inv=i["inv"], fields={
|
|
167
|
+
"statement": f"The instrument reads {WRONG}.", "kind": "finding", "scope": "A reading, taken once.",
|
|
168
|
+
"measurements": [{"result": i["rs1"], "key": "reading"}]})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_the_mcp_seat_answers_a_right_guess_like_a_wrong_one(tmp_path):
|
|
172
|
+
import asyncio
|
|
173
|
+
fastmcp = pytest.importorskip("fastmcp")
|
|
174
|
+
from hypgraph.mcp import build
|
|
175
|
+
|
|
176
|
+
def seat(x):
|
|
177
|
+
g, i = fixture()
|
|
178
|
+
out = []
|
|
179
|
+
|
|
180
|
+
async def go():
|
|
181
|
+
async with fastmcp.Client(build(g, author="checker")) as c:
|
|
182
|
+
for name, args in (("show", {"node": str(x)}),
|
|
183
|
+
("add", {"type": "result", "investigation": i["inv"],
|
|
184
|
+
"fields": {"summary": f"My reading is {x}.", "outcome": "positive",
|
|
185
|
+
"values": {"reading": x}},
|
|
186
|
+
"links": [{"rel": "supports", "to": "H1", "why": "the reading it predicts"}]})):
|
|
187
|
+
r = await c.call_tool(name, args, raise_on_error=False)
|
|
188
|
+
out.append((r.is_error, r.content[0].text))
|
|
189
|
+
asyncio.run(go())
|
|
190
|
+
return out
|
|
191
|
+
assert norm(seat(R), R) == norm(seat(WRONG), WRONG)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|