hypgraph 0.7.2__tar.gz → 0.7.4__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.2 → hypgraph-0.7.4}/CHANGELOG.md +49 -1
- {hypgraph-0.7.2 → hypgraph-0.7.4}/PKG-INFO +1 -1
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/spec.md +21 -8
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/derive.py +19 -1
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/graph.py +171 -33
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/refusals.py +131 -32
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/schema.py +41 -3
- hypgraph-0.7.4/tests/test_bound_support.py +55 -0
- hypgraph-0.7.4/tests/test_link_translate.py +73 -0
- hypgraph-0.7.4/tests/test_option_lead.py +52 -0
- hypgraph-0.7.4/tests/test_refusal_results.py +89 -0
- hypgraph-0.7.4/tests/test_relation_words.py +38 -0
- hypgraph-0.7.4/tests/test_shape_refusals.py +91 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/.gitignore +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/AGENTS.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/CITATION.cff +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/LICENSE +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/NOTICE +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/README.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/PLAN.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/site-plan.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/pyproject.toml +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/cli.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/server.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_coord.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_d10.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_engine.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
## 0.7.4 — 2026-10-28: a bound result is a line of support, and a relation word is read as the relation it names
|
|
4
|
+
|
|
5
|
+
- **A bound result is a line of support.** A claim's standing counted only `supports` edges into it, so a claim
|
|
6
|
+
that derives from a result and binds its numbers to it by `measurements` read "no live support", evidence
|
|
7
|
+
"unverified" — 73 of 113 active claims in the challenge records, each with its result right there. The results a
|
|
8
|
+
claim derives from and the results its measurements bind now count as lines of support (each once; shared runs
|
|
9
|
+
or data still merge into one line), so the evidence profile is the result's — lab-recorded when the instrument
|
|
10
|
+
filed it — and `unsupported` means what it says.
|
|
11
|
+
- **A relation word the grammar doesn't have is read as the relation it names.** "generated", "created", "wrote"
|
|
12
|
+
→ `produced`; "ran", "executed" → `executes`; "test" → `tests`; "answer" → `answers`; "used", "reads" → `uses`;
|
|
13
|
+
"contradicts", "refutes" → `challenges`; and so on (`schema.REL_SYNONYMS`, one reading each), noted on the
|
|
14
|
+
link like a translation. A hypothesis "derived from" a claim or result is `motivated_by` it; an experiment
|
|
15
|
+
"derived from" a hypothesis `tests` it. The link refusals left in the runs after 0.7.3: an unknown relation
|
|
16
|
+
`generated`, `derived_from hypothesis → claim`.
|
|
17
|
+
|
|
18
|
+
## 0.7.3 — 2026-10-28: the refusal log sees everything — results, shape, links — and reads a link as the relation that joins
|
|
19
|
+
|
|
20
|
+
- **A link is read as the relation that joins.** A relation that can't join its two types, where one relation says
|
|
21
|
+
the same thing between them, is applied as that relation with a note, like a link turned round: an experiment
|
|
22
|
+
that "supports" a hypothesis *tests* it; a run that "uses" an experiment *executes* it; a result, experiment or
|
|
23
|
+
decision that "answers" a question is *part of* it; a claim or result that "uses" or "rests on" an artifact is
|
|
24
|
+
*derived from* it; a decision that "supports" a claim is *based on* it (`schema.TRANSLATE`). An experiment may
|
|
25
|
+
`uses` the artifact it is designed on. A link refused on its pair is matched as fixed by the link that then
|
|
26
|
+
lands between the same nodes. Read off 23 `LINK_NOT_ALLOWED` refusals in nine runs: every hint named the
|
|
27
|
+
relation, 3 were taken; 19 of the 23 pairs now go through.
|
|
28
|
+
- **Shape refusals are logged, coded, and say where the thing goes.** A sixteen-challenge sweep's run logs held
|
|
29
|
+
217 refusals for a field, an option or a link the schema doesn't have, against the 79 overclaims its refusal
|
|
30
|
+
logs held — the larger friction stream, and invisible to the log, the report and the replay. Now `clean_body`
|
|
31
|
+
and the link check raise a `GraphError` whose `problems` carry a code (`SHAPE_UNKNOWN_FIELD`,
|
|
32
|
+
`SHAPE_NOT_AN_OPTION`, `SHAPE_MISSING_FIELD`, `SHAPE_TOO_LONG`, `SHAPE_TOO_SHORT`, `SHAPE_WRONG_TYPE`,
|
|
33
|
+
`LINK_NOT_ALLOWED`, `LINK_UNKNOWN_RELATION`, `LINK_NO_NODE`) and a fix; `create`, `revise` and `link` log the
|
|
34
|
+
refusal with the body (`detail.type` `shape` / `link`) before raising it; `refusal_report` counts them apart under
|
|
35
|
+
`shape`; `replay_refusals` skips them. The three messages the sweep hit most now say where the thing goes: `code`
|
|
36
|
+
on a result belongs to the run (`code=[…]`, `produced` → the result); an experiment's `expected` goes on its
|
|
37
|
+
`tests` link (`data: {expected: …}`); and `outcome must be one of …` explains each option. An option written as
|
|
38
|
+
a leading word and a sentence ("negative — no outcome remains significant after correction", "H1 confirmed: the
|
|
39
|
+
setting loses its edge") is read as the word, the sentence kept as the evidence note; a leading "fail" stays
|
|
40
|
+
refused as ambiguous (negative if the code ran and showed the opposite, failed if it didn't run). Plural and
|
|
41
|
+
result-kind words on a claim's `kind` (`findings`, `computed`) map to `finding`. Read off the 17 outcome bodies
|
|
42
|
+
the new log captured in the first runs on the branch: 9 were whole sentences with no option word (refused, now
|
|
43
|
+
with the fix), 8 began with one.
|
|
44
|
+
- **The refusal log treats results like claims.** A refused result is logged in full (its summary as the statement,
|
|
45
|
+
`RESULT_RESTATES` included), the author's next accepted result that says the same thing — by its words, or by
|
|
46
|
+
carrying at least half of the refused result's value keys again — fixes it (`fixes_refusals`, and the report's
|
|
47
|
+
*fixed* column, which showed 0 for every result code), and `replay_refusals` runs results through the value and
|
|
48
|
+
profile result checks as their author, claims through the profile checks too (an `EDGE_WITHOUT_TRIALS` refusal
|
|
49
|
+
used to replay as `passes`), and counts `RESULT_RESTATES` and typed-node refusals as skipped instead of replaying
|
|
50
|
+
a decision's choice as a claim. Found replaying 846 refusals from the challenge studies.
|
|
3
51
|
## 0.7.2 — 2026-10-28: the honest sentences the rules were refusing, and a nominal effect can't hide its correction
|
|
4
52
|
|
|
5
53
|
- **Honest sentences the rules were refusing.** Read off every refusal logged in 96 records of the challenge studies
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
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/
|
|
@@ -41,7 +41,7 @@ correction is a new row, never an edited one.
|
|
|
41
41
|
| `tests` | experiment | claim, hypothesis | an experiment tests a hypothesis or claim (data.expected: what it should show if true) |
|
|
42
42
|
| `executes` | run | experiment | a run carries out an experiment |
|
|
43
43
|
| `produced` | run | artifact, result | a run produced a result or artifact |
|
|
44
|
-
| `uses` | run | artifact | a run used an artifact (data.role: data / code / model / config) |
|
|
44
|
+
| `uses` | run, experiment | artifact | a run used an artifact (data.role: data / code / model / config); an experiment is designed on one |
|
|
45
45
|
| `derived_from` | artifact, claim, result | artifact, result, run, source | computed or taken from |
|
|
46
46
|
| `supports` | claim, result, source | claim, hypothesis | evidence for (why required) |
|
|
47
47
|
| `challenges` | claim, result, source | claim, hypothesis | evidence against (why required) |
|
|
@@ -199,13 +199,26 @@ A refused claim never enters the record, so its trace lives in a separate append
|
|
|
199
199
|
its own SHA-256 chain over `canon({at, kind, author, inv, node, op, statement, codes, detail, ref, prev})`. The
|
|
200
200
|
record's own chain and `verify()` are unaffected.
|
|
201
201
|
|
|
202
|
-
- `kind: refused`: the author, the investigation, the statement (≤ 1,500 characters
|
|
203
|
-
problems (code, message, fix) it was refused with
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
202
|
+
- `kind: refused`: the author, the investigation, the statement (≤ 1,500 characters; a result's summary), the
|
|
203
|
+
sorted codes, and the problems (code, message, fix) it was refused with; `detail` holds the whole body, the
|
|
204
|
+
support it had and the node type (`claim`, `result`, or a typed node). `op` is `create` or `revise`; `node` is
|
|
205
|
+
set for a revision.
|
|
206
|
+
- A refusal on *shape* — a field the type doesn't have (`SHAPE_UNKNOWN_FIELD`), a value outside an enum
|
|
207
|
+
(`SHAPE_NOT_AN_OPTION`), a missing, too long or too short field, a wrong type, or a link the relation grammar
|
|
208
|
+
refuses (`LINK_NOT_ALLOWED`, `LINK_UNKNOWN_RELATION`, `LINK_NO_NODE`) — is logged the same way with
|
|
209
|
+
`detail.type` `shape` or `link`. Its message carries the fix: where the field goes (`code` on a result belongs to
|
|
210
|
+
the run; an experiment's `expected` goes on its `tests` link) or what each option means. The report counts these
|
|
211
|
+
apart from overclaims and the replay skips them.
|
|
212
|
+
- `kind: fixed`: a later claim, result or revision by the same author in the same investigation, accepted within
|
|
213
|
+
24 hours, that says the same thing. "The same" means the same node for a revision; otherwise a Jaccard overlap
|
|
214
|
+
≥ 0.3 of the statements' content words, or, for a result, at least half of the refused result's value keys carried
|
|
215
|
+
again. `ref` is the refusal it fixes.
|
|
216
|
+
|
|
217
|
+
A report aggregates per author (refused, fixed, open, codes) and per rule. A replay runs every logged refusal through
|
|
218
|
+
the checker as it is now — claims through the claim and profile checks, results through the value and profile result
|
|
219
|
+
checks as their author — and says for each whether it would be refused the same way, differently, or accepted;
|
|
220
|
+
`RESULT_RESTATES` and typed-node refusals depend on the record's state at the time and are counted as skipped. A
|
|
221
|
+
change to a rule is judged on that replay over the real corpus before it ships.
|
|
209
222
|
|
|
210
223
|
## 9. Checking a write-up against the record
|
|
211
224
|
|
|
@@ -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.4"
|
|
20
20
|
|
|
21
21
|
from .bundle import verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -349,7 +349,25 @@ def dependents(s: Snap, nid: str, limit: int = 500) -> list[tuple[str, str, int]
|
|
|
349
349
|
|
|
350
350
|
|
|
351
351
|
def supporters(s: Snap, nid: str) -> list[dict]:
|
|
352
|
-
|
|
352
|
+
"""What stands behind a node: every `supports` edge into it, and — for a claim — the results it is derived from
|
|
353
|
+
and the results its measurements bind (each once). A claim that binds its numbers to a result rests on that
|
|
354
|
+
result whether or not anyone also wrote `supports`; until 0.7.4 only the edge counted, and most honest claims
|
|
355
|
+
read "no live support" with their evidence "unverified"."""
|
|
356
|
+
out = [e for e in s.inn.get(nid, []) if e["rel"] == "supports"]
|
|
357
|
+
n = s.nodes.get(nid)
|
|
358
|
+
if n and n["type"] == "claim":
|
|
359
|
+
seen = {e["src"] for e in out}
|
|
360
|
+
for e in s.out.get(nid, []):
|
|
361
|
+
if e["rel"] == "derived_from" and e["dst"] in s.nodes and s.nodes[e["dst"]]["type"] == "result" \
|
|
362
|
+
and e["dst"] not in seen:
|
|
363
|
+
seen.add(e["dst"])
|
|
364
|
+
out.append({"src": e["dst"], "rel": "derived_from", "dst": nid, "data": {}, "by": n["created_by"]})
|
|
365
|
+
for m in n["body"].get("measurements") or []:
|
|
366
|
+
rid = m.get("result") if isinstance(m, dict) else None
|
|
367
|
+
if rid and rid in s.nodes and s.nodes[rid]["type"] == "result" and rid not in seen:
|
|
368
|
+
seen.add(rid)
|
|
369
|
+
out.append({"src": rid, "rel": "measurement", "dst": nid, "data": {}, "by": n["created_by"]})
|
|
370
|
+
return out
|
|
353
371
|
|
|
354
372
|
|
|
355
373
|
def at_risk(s: Snap, nid: str) -> list[str]:
|
|
@@ -31,7 +31,7 @@ from . import derive as D
|
|
|
31
31
|
from . import refusals, store
|
|
32
32
|
from .checks import claim_issues, value_issues
|
|
33
33
|
from .policy import Policy
|
|
34
|
-
from .schema import REPLICATION, RELS, SAME_TYPE, SYNONYMS, TYPES, VERDICTS
|
|
34
|
+
from .schema import REL_SYNONYMS, REPLICATION, RELS, SAME_TYPE, SYNONYMS, TRANSLATE, TYPES, VERDICTS
|
|
35
35
|
from .store import canon, now
|
|
36
36
|
|
|
37
37
|
|
|
@@ -47,49 +47,116 @@ class GraphError(ValueError):
|
|
|
47
47
|
SpendFn = Callable[..., dict]
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def _shape(code: str, message: str, fix: str = "", words: list | None = None) -> GraphError:
|
|
51
|
+
"""A shape refusal: the message, and the fix as its own field so a host can show it and the log can count it."""
|
|
52
|
+
return GraphError(message + (f" — {fix}" if fix else ""),
|
|
53
|
+
[{"code": code, "message": message, "fix": fix, "words": list(words or [])}])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# where a field an agent reaches for actually goes (read off 217 shape refusals in a sixteen-challenge sweep)
|
|
57
|
+
_UNKNOWN_FIX = {
|
|
58
|
+
("result", "code"): "the code belongs to the run: register the run with code=[…] (and uses → the script's "
|
|
59
|
+
"artifact), then link it produced → this result",
|
|
60
|
+
("result", "name"): "a result has a summary, not a name; the run has the name",
|
|
61
|
+
("result", "uses"): "uses is the run's link to the artifacts it read, not a result field",
|
|
62
|
+
("experiment", "data"): "expected goes on the link that tests the hypothesis: links=[{rel: 'tests', to: 'H1', "
|
|
63
|
+
"data: {expected: 'what this experiment shows if H1 is true'}}]",
|
|
64
|
+
("experiment", "data.expected"): "expected goes on the link that tests the hypothesis: links=[{rel: 'tests', "
|
|
65
|
+
"to: 'H1', data: {expected: '…'}}]",
|
|
66
|
+
("claim", "fields"): "pass the claim's fields themselves (statement, scope, kind, measurements), not a fields "
|
|
67
|
+
"object",
|
|
68
|
+
}
|
|
69
|
+
_OPTION_FIX = {
|
|
70
|
+
("result", "outcome"): "positive: what the summary describes was seen; negative: it was not; inconclusive: "
|
|
71
|
+
"neither; failed: the code didn't run; skipped: it wasn't run — one word; the sentence "
|
|
72
|
+
"goes in the summary or evidence_note",
|
|
73
|
+
("result", "kind"): "computed: from code you ran; observed: measured or read off data; cited: taken from a "
|
|
74
|
+
"source",
|
|
75
|
+
("claim", "kind"): "finding: what the evidence shows; exclusion: what it rules out; inconclusive; convention: "
|
|
76
|
+
"a definition; lead: a hunch to test",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_AMBIGUOUS_LEAD = {"fail", "failed", "failure", "error", "errored"} # the thing under test, or the code?
|
|
81
|
+
_LEAD = re.compile(r"^\s*([A-Za-z][A-Za-z0-9_' -]{0,40}?)\s*(?:—|–|:|-{1,2})\s+(\S.*)$", re.S)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _leading_option(raw: str, syn: dict, opts: list) -> tuple[str, str]:
|
|
85
|
+
"""An option written as a leading word and a sentence — "negative — nothing survives correction",
|
|
86
|
+
"H1 confirmed: the setting loses" — read as (option, the sentence); ('', '') when there is no such word, and
|
|
87
|
+
('?', '') when the word is one whose meaning differs between the thing tested and the code ("fail")."""
|
|
88
|
+
m = _LEAD.match(raw or "")
|
|
89
|
+
if not m:
|
|
90
|
+
return "", ""
|
|
91
|
+
lead, rest = m.group(1).strip().lower(), m.group(2).strip()
|
|
92
|
+
tokens = [lead.replace(" ", "_").replace("-", "_")] + lead.replace("-", " ").split()
|
|
93
|
+
for t in tokens:
|
|
94
|
+
if t in _AMBIGUOUS_LEAD:
|
|
95
|
+
return "?", ""
|
|
96
|
+
v = syn.get(t, t)
|
|
97
|
+
if v in opts:
|
|
98
|
+
return v, rest
|
|
99
|
+
return "", ""
|
|
100
|
+
|
|
101
|
+
|
|
50
102
|
def clean_body(ntype: str, body: dict, *, partial_ok: bool = False) -> dict:
|
|
51
|
-
"""Validate and normalise a node's fields against its type (schema.TYPES).
|
|
103
|
+
"""Validate and normalise a node's fields against its type (schema.TYPES). Every refusal carries a SHAPE_* code
|
|
104
|
+
and, where the sweep showed agents reaching for a field that lives elsewhere, says where it goes."""
|
|
52
105
|
spec = TYPES[ntype]["fields"]
|
|
53
106
|
if not isinstance(body, dict):
|
|
54
|
-
raise
|
|
107
|
+
raise _shape("SHAPE_NOT_AN_OBJECT", "fields must be an object")
|
|
55
108
|
unknown = sorted(set(body) - set(spec))
|
|
56
109
|
if unknown:
|
|
57
|
-
|
|
110
|
+
fixes = [f for k in unknown if (f := _UNKNOWN_FIX.get((ntype, k)))]
|
|
111
|
+
raise _shape("SHAPE_UNKNOWN_FIELD", f"{ntype} has no field(s) {unknown}; fields: {', '.join(spec)}",
|
|
112
|
+
"; ".join(fixes) if fixes else f"drop {', '.join(unknown)}", unknown)
|
|
58
113
|
out = {}
|
|
59
114
|
for k, (kind, n, req) in spec.items():
|
|
60
115
|
v = body.get(k)
|
|
61
116
|
if v in (None, "", [], {}):
|
|
62
117
|
if req and not partial_ok:
|
|
63
|
-
raise
|
|
118
|
+
raise _shape("SHAPE_MISSING_FIELD", f"{ntype} needs {k}", words=[k])
|
|
64
119
|
continue
|
|
65
120
|
if kind in ("str", "text"):
|
|
66
121
|
v = str(v).strip()
|
|
67
122
|
if len(v) > n:
|
|
68
|
-
raise
|
|
123
|
+
raise _shape("SHAPE_TOO_LONG", f"{k} is longer than {n} characters", f"shorten {k} to {n} characters",
|
|
124
|
+
[k])
|
|
69
125
|
if req and len(v) < (3 if kind == "str" else 10):
|
|
70
|
-
raise
|
|
126
|
+
raise _shape("SHAPE_TOO_SHORT", f"{k}: say it in a full sentence", words=[k])
|
|
71
127
|
elif kind.startswith("enum:"):
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
128
|
+
opts, syn, raw = kind[5:].split("|"), SYNONYMS.get((ntype, k), {}), str(v).strip()
|
|
129
|
+
v = syn.get(raw.lower().replace(" ", "_").replace("-", "_"), raw)
|
|
130
|
+
if str(v) not in opts:
|
|
131
|
+
lead, rest = _leading_option(raw, syn, opts)
|
|
132
|
+
if lead and lead != "?":
|
|
133
|
+
v = lead # the word is the option; the sentence stays as the note
|
|
134
|
+
if rest and k == "outcome" and "evidence_note" in spec and not str(body.get("evidence_note") or "").strip():
|
|
135
|
+
out["evidence_note"] = rest[:spec["evidence_note"][1]]
|
|
136
|
+
else:
|
|
137
|
+
fix = _OPTION_FIX.get((ntype, k), "")
|
|
138
|
+
if lead == "?":
|
|
139
|
+
fix = ("a leading 'fail' is ambiguous: negative if the code ran and showed the opposite of what "
|
|
140
|
+
"the summary tests, failed if the code didn't run" + (f"; {fix}" if fix else ""))
|
|
141
|
+
raise _shape("SHAPE_NOT_AN_OPTION", f"{k} must be one of {kind[5:].replace('|', ', ')}", fix, [k])
|
|
75
142
|
v = str(v)
|
|
76
143
|
elif kind == "list":
|
|
77
144
|
if isinstance(v, str):
|
|
78
145
|
v = [v]
|
|
79
146
|
if not isinstance(v, list) or len(v) > n:
|
|
80
|
-
raise
|
|
147
|
+
raise _shape("SHAPE_WRONG_TYPE", f"{k} must be a list of at most {n} items", words=[k])
|
|
81
148
|
elif kind == "dict":
|
|
82
149
|
if not isinstance(v, dict) or len(canon(v)) > n:
|
|
83
|
-
raise
|
|
150
|
+
raise _shape("SHAPE_WRONG_TYPE", f"{k} must be an object (at most {n} characters)", words=[k])
|
|
84
151
|
elif kind == "bool":
|
|
85
152
|
v = bool(v)
|
|
86
153
|
elif kind == "num":
|
|
87
154
|
v = float(v)
|
|
88
155
|
if not math.isfinite(v):
|
|
89
|
-
raise
|
|
156
|
+
raise _shape("SHAPE_WRONG_TYPE", f"{k} must be a finite number", words=[k])
|
|
90
157
|
elif kind == "any":
|
|
91
158
|
if len(canon(v)) > n:
|
|
92
|
-
raise
|
|
159
|
+
raise _shape("SHAPE_TOO_LONG", f"{k} is too long", words=[k])
|
|
93
160
|
out[k] = v
|
|
94
161
|
return out
|
|
95
162
|
|
|
@@ -392,6 +459,32 @@ class Graph:
|
|
|
392
459
|
k += 1
|
|
393
460
|
return f"{prefix}{k}"
|
|
394
461
|
|
|
462
|
+
@staticmethod
|
|
463
|
+
def _translate(s: D.Snap, src: str, rel: str, dst: str) -> tuple[str, str]:
|
|
464
|
+
"""A relation that can't join these two types, read as the one that says the same thing between them
|
|
465
|
+
(schema.TRANSLATE), or a word the grammar doesn't have read as the relation it names (schema.REL_SYNONYMS):
|
|
466
|
+
the relation to apply and a note, or the given relation and ''."""
|
|
467
|
+
given = rel
|
|
468
|
+
if rel not in RELS:
|
|
469
|
+
word = str(rel or "").strip().lower().replace(" ", "_").replace("-", "_")
|
|
470
|
+
rel = REL_SYNONYMS.get(word, rel)
|
|
471
|
+
if rel in RELS and src in s.nodes and dst in s.nodes:
|
|
472
|
+
st, dt = s.nodes[src]["type"], s.nodes[dst]["type"]
|
|
473
|
+
srcs, dsts = RELS[rel]
|
|
474
|
+
if (st in srcs and dt in dsts) or (dt in srcs and st in dsts):
|
|
475
|
+
return rel, (f"{s.nodes[src].get('code') or src} -{given}-> {s.nodes[dst].get('code') or dst} "
|
|
476
|
+
f"read as {rel}")
|
|
477
|
+
if rel in RELS and src in s.nodes and dst in s.nodes:
|
|
478
|
+
st, dt = s.nodes[src]["type"], s.nodes[dst]["type"]
|
|
479
|
+
srcs, dsts = RELS[rel]
|
|
480
|
+
if not (st in srcs and dt in dsts) and not (dt in srcs and st in dsts):
|
|
481
|
+
new = TRANSLATE.get((rel, st, dt))
|
|
482
|
+
if new:
|
|
483
|
+
return new, (f"{s.nodes[src].get('code') or src} -{given}-> {s.nodes[dst].get('code') or dst} "
|
|
484
|
+
f"read as {new} (a {st} {new.replace('_', ' ')} a {dt}; {rel} joins "
|
|
485
|
+
f"{', '.join(sorted(srcs))} to {', '.join(sorted(dsts))})")
|
|
486
|
+
return rel, ""
|
|
487
|
+
|
|
395
488
|
@staticmethod
|
|
396
489
|
def _orient(s: D.Snap, src: str, rel: str, dst: str) -> tuple[str, str, str, bool]:
|
|
397
490
|
"""A link given backwards — a claim that 'supports' its result — is turned round when only the reverse is
|
|
@@ -406,10 +499,10 @@ class Graph:
|
|
|
406
499
|
@staticmethod
|
|
407
500
|
def _check_link(s: D.Snap, src: str, rel: str, dst: str) -> None:
|
|
408
501
|
if rel not in RELS:
|
|
409
|
-
raise
|
|
502
|
+
raise _shape("LINK_UNKNOWN_RELATION", f"unknown relation {rel!r}; relations: {', '.join(RELS)}", words=[rel])
|
|
410
503
|
for x in (src, dst):
|
|
411
504
|
if x not in s.nodes:
|
|
412
|
-
raise
|
|
505
|
+
raise _shape("LINK_NO_NODE", f"no node {x}", "name a node the record holds (its code or id)", [x])
|
|
413
506
|
st, dt = s.nodes[src]["type"], s.nodes[dst]["type"]
|
|
414
507
|
srcs, dsts = RELS[rel]
|
|
415
508
|
if st not in srcs or dt not in dsts:
|
|
@@ -417,8 +510,8 @@ class Graph:
|
|
|
417
510
|
back = [r for r, (a, b) in RELS.items() if dt in a and st in b and r not in SAME_TYPE and r not in joins]
|
|
418
511
|
hint = (f" — a {st} joins a {dt} with {', '.join(joins)}" if joins else "") + \
|
|
419
512
|
(f"; the other way round, a {dt} joins a {st} with {', '.join(back)}" if back else "")
|
|
420
|
-
raise
|
|
421
|
-
|
|
513
|
+
raise _shape("LINK_NOT_ALLOWED", f"{rel} can't join {st} → {dt} (allowed: {', '.join(sorted(srcs))} → "
|
|
514
|
+
f"{', '.join(sorted(dsts))}){hint}", words=[rel, st, dt])
|
|
422
515
|
if rel in SAME_TYPE and st != dt:
|
|
423
516
|
raise GraphError(f"{rel} joins two nodes of the same type")
|
|
424
517
|
if src == dst:
|
|
@@ -539,7 +632,13 @@ class Graph:
|
|
|
539
632
|
raise GraphError(f"unknown type {type!r}; types: {', '.join(TYPES)}")
|
|
540
633
|
if type == "review":
|
|
541
634
|
raise GraphError("use review() to record a review")
|
|
542
|
-
|
|
635
|
+
try:
|
|
636
|
+
body = clean_body(type, fields)
|
|
637
|
+
except GraphError as e:
|
|
638
|
+
refusals.record_refusal(self, author=author, op="create", inv=inv or "", node="",
|
|
639
|
+
body=fields if isinstance(fields, dict) else {},
|
|
640
|
+
problems=e.problems or [{"code": "SHAPE", "message": str(e)}], type="shape")
|
|
641
|
+
raise
|
|
543
642
|
links = [l for l in (links or []) if isinstance(l, dict)]
|
|
544
643
|
with self._lock:
|
|
545
644
|
try:
|
|
@@ -578,7 +677,15 @@ class Graph:
|
|
|
578
677
|
staged = {"id": nid, "type": type, "code": "", "inv": inv, "created_at": at, "created_by": who,
|
|
579
678
|
"v": 1, "body": body}
|
|
580
679
|
s.nodes[nid] = staged # staged for link checks; dropped if the write fails
|
|
581
|
-
|
|
680
|
+
specs, translated = [], []
|
|
681
|
+
for l in links:
|
|
682
|
+
a, rel, b = self._link_spec(nid, l)
|
|
683
|
+
given, (rel, note) = rel, self._translate(s, a, rel, b)
|
|
684
|
+
if note:
|
|
685
|
+
translated.append(note)
|
|
686
|
+
l["data"] = {**(l.get("data") or {}), "read_as": given} # what the author wrote
|
|
687
|
+
specs.append((a, rel, b))
|
|
688
|
+
oriented = [self._orient(s, *x) for x in specs]
|
|
582
689
|
pending = [(a, rel, b) for a, rel, b, _f in oriented]
|
|
583
690
|
flipped = [f"{s.nodes[a].get('code') or a} -{rel}-> {s.nodes[b].get('code') or b}"
|
|
584
691
|
for a, rel, b, f in oriented if f]
|
|
@@ -628,7 +735,7 @@ class Graph:
|
|
|
628
735
|
if rp:
|
|
629
736
|
found = [p.to_dict() for p in rp]
|
|
630
737
|
refusals.record_refusal(self, author=author, op="create", inv=inv, node="", body=body,
|
|
631
|
-
problems=found, support=[])
|
|
738
|
+
problems=found, support=[], type="result")
|
|
632
739
|
raise GraphError("result refused:\n- " + "\n- ".join(p.text() for p in rp), found)
|
|
633
740
|
if type in ("artifact", "source", "decision") and author not in P.curators:
|
|
634
741
|
np_ = profiles.node_issues(s, inv, type, body, pending) # the workflow's typed shapes
|
|
@@ -637,7 +744,7 @@ class Graph:
|
|
|
637
744
|
refusals.record_refusal(self, author=author, op="create", inv=inv, node="",
|
|
638
745
|
body={"statement": str(body.get("choice") or body.get("citation")
|
|
639
746
|
or body.get("path") or ""), "kind": type},
|
|
640
|
-
problems=found)
|
|
747
|
+
problems=found, type=type)
|
|
641
748
|
raise GraphError(f"{type} refused:\n- " + "\n- ".join(p.text() for p in np_), found)
|
|
642
749
|
if type == "result" and author not in P.reserved:
|
|
643
750
|
same = self._restated_by(s, body, inv, nid)
|
|
@@ -648,9 +755,8 @@ class Graph:
|
|
|
648
755
|
c = s.nodes[mine[0]]["code"] or mine[0]
|
|
649
756
|
found = [{"code": "RESULT_RESTATES", "message": f"this result repeats the numbers of your own "
|
|
650
757
|
f"{c}", "fix": f"bind claims to {c} instead", "words": []}]
|
|
651
|
-
refusals.record_refusal(self, author=author, op="create", inv=inv, node="",
|
|
652
|
-
|
|
653
|
-
problems=found)
|
|
758
|
+
refusals.record_refusal(self, author=author, op="create", inv=inv, node="", body=body,
|
|
759
|
+
problems=found, type="result")
|
|
654
760
|
raise GraphError(f"result refused: it repeats the numbers of your own {c} — bind claims to "
|
|
655
761
|
f"{c} instead of writing it again; a genuine rerun is a run that replicates "
|
|
656
762
|
f"{c}'s run [RESULT_RESTATES]", found)
|
|
@@ -667,12 +773,19 @@ class Graph:
|
|
|
667
773
|
for (a, rel, b), l in zip(pending, links):
|
|
668
774
|
self._insert_edge(con, s, a, rel, b, l.get("data") or {}, l.get("why") or "", author, at)
|
|
669
775
|
self._transaction(write)
|
|
776
|
+
except GraphError as e:
|
|
777
|
+
if e.problems and str(e.problems[0].get("code", "")).startswith("LINK_"):
|
|
778
|
+
refusals.record_refusal(self, author=author, op="create", inv=inv or "", node="",
|
|
779
|
+
body=fields if isinstance(fields, dict) else {}, problems=e.problems,
|
|
780
|
+
type="link")
|
|
781
|
+
raise
|
|
670
782
|
finally:
|
|
671
783
|
# the staged node lives in the cached snapshot: a failed write drops it; after a commit the next
|
|
672
784
|
# read loads the committed rows over it
|
|
673
785
|
self._after_write()
|
|
674
786
|
out = self.get(nid)
|
|
675
787
|
notes = [f"link turned round to the direction the relation allows: {x}" for x in flipped]
|
|
788
|
+
notes += [f"link read as the relation that joins: {x}" for x in translated]
|
|
676
789
|
if auto_notes:
|
|
677
790
|
notes.append("bound by the record: " + ", ".join(auto_notes))
|
|
678
791
|
out["auto_bound"] = auto_notes
|
|
@@ -681,9 +794,10 @@ class Graph:
|
|
|
681
794
|
if out["state"].get("restates"):
|
|
682
795
|
out["restates"] = [self.get(r)["code"] for r in out["state"]["restates"]]
|
|
683
796
|
self._with_orders(out, author, inv)
|
|
684
|
-
if type
|
|
685
|
-
fixed = refusals.match_fix(self, author=author, inv=inv, node=nid,
|
|
686
|
-
|
|
797
|
+
if type in refusals.REPLAYABLE and author not in P.curators:
|
|
798
|
+
fixed = refusals.match_fix(self, author=author, inv=inv, node=nid, op="create",
|
|
799
|
+
statement=str(body.get("statement") or body.get("summary") or ""),
|
|
800
|
+
values=body.get("values") if type == "result" else None)
|
|
687
801
|
if fixed:
|
|
688
802
|
out["fixes_refusals"] = fixed
|
|
689
803
|
return out
|
|
@@ -835,7 +949,13 @@ class Graph:
|
|
|
835
949
|
if not self._may_revise(s, author, n):
|
|
836
950
|
raise GraphError(f"only {n['created_by']} (its author), a reviewer or the {P.operator} revises "
|
|
837
951
|
f"{n['code']}; challenge it, or propose a node that supersedes it")
|
|
838
|
-
|
|
952
|
+
try:
|
|
953
|
+
body = clean_body(n["type"], {**n["body"], **(fields or {})})
|
|
954
|
+
except GraphError as e:
|
|
955
|
+
refusals.record_refusal(self, author=author, op="revise", inv=n["inv"], node=node,
|
|
956
|
+
body=fields if isinstance(fields, dict) else {},
|
|
957
|
+
problems=e.problems or [{"code": "SHAPE", "message": str(e)}], type="shape")
|
|
958
|
+
raise
|
|
839
959
|
extra: list[str] = []
|
|
840
960
|
auto_notes: list[str] = []
|
|
841
961
|
if n["type"] == "claim":
|
|
@@ -876,9 +996,10 @@ class Graph:
|
|
|
876
996
|
out["notes"] = ["bound by the record: " + ", ".join(auto_notes)]
|
|
877
997
|
out["auto_bound"] = auto_notes
|
|
878
998
|
self._with_orders(out, author, out["inv"])
|
|
879
|
-
if out["type"]
|
|
880
|
-
fixed = refusals.match_fix(self, author=author, inv=out["inv"], node=node,
|
|
881
|
-
statement=out["body"].get("statement"
|
|
999
|
+
if out["type"] in refusals.REPLAYABLE and author not in P.curators:
|
|
1000
|
+
fixed = refusals.match_fix(self, author=author, inv=out["inv"], node=node, op="revise",
|
|
1001
|
+
statement=str(out["body"].get("statement") or out["body"].get("summary") or ""),
|
|
1002
|
+
values=out["body"].get("values") if out["type"] == "result" else None)
|
|
882
1003
|
if fixed:
|
|
883
1004
|
out["fixes_refusals"] = fixed
|
|
884
1005
|
return out
|
|
@@ -889,14 +1010,31 @@ class Graph:
|
|
|
889
1010
|
with self._lock:
|
|
890
1011
|
try:
|
|
891
1012
|
s = self.snapshot()
|
|
1013
|
+
given, (rel, translated) = rel, self._translate(s, src, rel, dst)
|
|
1014
|
+
if translated:
|
|
1015
|
+
data = {**(data or {}), "read_as": given} # what the author wrote
|
|
892
1016
|
src, rel, dst, flipped = self._orient(s, src, rel, dst)
|
|
893
1017
|
if src in s.nodes:
|
|
894
1018
|
self._fixed_guard(s, author, src)
|
|
895
1019
|
eid = self._transaction(lambda con: self._insert_edge(con, s, src, rel, dst, data or {}, why, author,
|
|
896
1020
|
self.clock()))
|
|
1021
|
+
except GraphError as e:
|
|
1022
|
+
if e.problems and str(e.problems[0].get("code", "")).startswith("LINK_"):
|
|
1023
|
+
refusals.record_refusal(self, author=author, op="link", inv=(s.nodes.get(src) or {}).get("inv", ""),
|
|
1024
|
+
node="", body={"statement": f"{src} {rel} {dst}"}, problems=e.problems,
|
|
1025
|
+
type="link")
|
|
1026
|
+
raise
|
|
897
1027
|
finally:
|
|
898
1028
|
self._after_write()
|
|
899
|
-
|
|
1029
|
+
out = {"edge": eid, "src": src, "rel": rel, "dst": dst, "flipped": flipped}
|
|
1030
|
+
if translated:
|
|
1031
|
+
out["notes"] = [f"link read as the relation that joins: {translated}"]
|
|
1032
|
+
if author not in self.policy.curators:
|
|
1033
|
+
fixed = refusals.match_fix(self, author=author, inv=(s.nodes.get(src) or {}).get("inv", ""), node="",
|
|
1034
|
+
statement="", op="link", pair=(src, dst))
|
|
1035
|
+
if fixed:
|
|
1036
|
+
out["fixes_refusals"] = fixed
|
|
1037
|
+
return out
|
|
900
1038
|
|
|
901
1039
|
def retract(self, *, author: str, edge: int, why: str) -> dict:
|
|
902
1040
|
if len(str(why or "").strip()) < 10:
|