hypgraph 0.7.2__tar.gz → 0.7.3__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.3}/CHANGELOG.md +33 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/PKG-INFO +1 -1
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/spec.md +21 -8
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/graph.py +160 -33
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/refusals.py +131 -32
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/schema.py +22 -3
- hypgraph-0.7.3/tests/test_link_translate.py +73 -0
- hypgraph-0.7.3/tests/test_option_lead.py +52 -0
- hypgraph-0.7.3/tests/test_refusal_results.py +89 -0
- hypgraph-0.7.3/tests/test_shape_refusals.py +91 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/.gitignore +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/AGENTS.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/CITATION.cff +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/LICENSE +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/NOTICE +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/README.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/PLAN.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/site-plan.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/pyproject.toml +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/cli.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/server.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_coord.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_d10.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_engine.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.3 — 2026-10-28: the refusal log sees everything — results, shape, links — and reads a link as the relation that joins
|
|
4
|
+
|
|
5
|
+
- **A link is read as the relation that joins.** A relation that can't join its two types, where one relation says
|
|
6
|
+
the same thing between them, is applied as that relation with a note, like a link turned round: an experiment
|
|
7
|
+
that "supports" a hypothesis *tests* it; a run that "uses" an experiment *executes* it; a result, experiment or
|
|
8
|
+
decision that "answers" a question is *part of* it; a claim or result that "uses" or "rests on" an artifact is
|
|
9
|
+
*derived from* it; a decision that "supports" a claim is *based on* it (`schema.TRANSLATE`). An experiment may
|
|
10
|
+
`uses` the artifact it is designed on. A link refused on its pair is matched as fixed by the link that then
|
|
11
|
+
lands between the same nodes. Read off 23 `LINK_NOT_ALLOWED` refusals in nine runs: every hint named the
|
|
12
|
+
relation, 3 were taken; 19 of the 23 pairs now go through.
|
|
13
|
+
- **Shape refusals are logged, coded, and say where the thing goes.** A sixteen-challenge sweep's run logs held
|
|
14
|
+
217 refusals for a field, an option or a link the schema doesn't have, against the 79 overclaims its refusal
|
|
15
|
+
logs held — the larger friction stream, and invisible to the log, the report and the replay. Now `clean_body`
|
|
16
|
+
and the link check raise a `GraphError` whose `problems` carry a code (`SHAPE_UNKNOWN_FIELD`,
|
|
17
|
+
`SHAPE_NOT_AN_OPTION`, `SHAPE_MISSING_FIELD`, `SHAPE_TOO_LONG`, `SHAPE_TOO_SHORT`, `SHAPE_WRONG_TYPE`,
|
|
18
|
+
`LINK_NOT_ALLOWED`, `LINK_UNKNOWN_RELATION`, `LINK_NO_NODE`) and a fix; `create`, `revise` and `link` log the
|
|
19
|
+
refusal with the body (`detail.type` `shape` / `link`) before raising it; `refusal_report` counts them apart under
|
|
20
|
+
`shape`; `replay_refusals` skips them. The three messages the sweep hit most now say where the thing goes: `code`
|
|
21
|
+
on a result belongs to the run (`code=[…]`, `produced` → the result); an experiment's `expected` goes on its
|
|
22
|
+
`tests` link (`data: {expected: …}`); and `outcome must be one of …` explains each option. An option written as
|
|
23
|
+
a leading word and a sentence ("negative — no outcome remains significant after correction", "H1 confirmed: the
|
|
24
|
+
setting loses its edge") is read as the word, the sentence kept as the evidence note; a leading "fail" stays
|
|
25
|
+
refused as ambiguous (negative if the code ran and showed the opposite, failed if it didn't run). Plural and
|
|
26
|
+
result-kind words on a claim's `kind` (`findings`, `computed`) map to `finding`. Read off the 17 outcome bodies
|
|
27
|
+
the new log captured in the first runs on the branch: 9 were whole sentences with no option word (refused, now
|
|
28
|
+
with the fix), 8 began with one.
|
|
29
|
+
- **The refusal log treats results like claims.** A refused result is logged in full (its summary as the statement,
|
|
30
|
+
`RESULT_RESTATES` included), the author's next accepted result that says the same thing — by its words, or by
|
|
31
|
+
carrying at least half of the refused result's value keys again — fixes it (`fixes_refusals`, and the report's
|
|
32
|
+
*fixed* column, which showed 0 for every result code), and `replay_refusals` runs results through the value and
|
|
33
|
+
profile result checks as their author, claims through the profile checks too (an `EDGE_WITHOUT_TRIALS` refusal
|
|
34
|
+
used to replay as `passes`), and counts `RESULT_RESTATES` and typed-node refusals as skipped instead of replaying
|
|
35
|
+
a decision's choice as a claim. Found replaying 846 refusals from the challenge studies.
|
|
3
36
|
## 0.7.2 — 2026-10-28: the honest sentences the rules were refusing, and a nominal effect can't hide its correction
|
|
4
37
|
|
|
5
38
|
- **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.3
|
|
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.3"
|
|
20
20
|
|
|
21
21
|
from .bundle import verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -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 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,21 @@ 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): the relation to apply and a note, or the given relation and ''."""
|
|
466
|
+
if rel in RELS and src in s.nodes and dst in s.nodes:
|
|
467
|
+
st, dt = s.nodes[src]["type"], s.nodes[dst]["type"]
|
|
468
|
+
srcs, dsts = RELS[rel]
|
|
469
|
+
if not (st in srcs and dt in dsts) and not (dt in srcs and st in dsts):
|
|
470
|
+
new = TRANSLATE.get((rel, st, dt))
|
|
471
|
+
if new:
|
|
472
|
+
return new, (f"{s.nodes[src].get('code') or src} -{rel}-> {s.nodes[dst].get('code') or dst} "
|
|
473
|
+
f"read as {new} (a {st} {new.replace('_', ' ')} a {dt}; {rel} joins "
|
|
474
|
+
f"{', '.join(sorted(srcs))} to {', '.join(sorted(dsts))})")
|
|
475
|
+
return rel, ""
|
|
476
|
+
|
|
395
477
|
@staticmethod
|
|
396
478
|
def _orient(s: D.Snap, src: str, rel: str, dst: str) -> tuple[str, str, str, bool]:
|
|
397
479
|
"""A link given backwards — a claim that 'supports' its result — is turned round when only the reverse is
|
|
@@ -406,10 +488,10 @@ class Graph:
|
|
|
406
488
|
@staticmethod
|
|
407
489
|
def _check_link(s: D.Snap, src: str, rel: str, dst: str) -> None:
|
|
408
490
|
if rel not in RELS:
|
|
409
|
-
raise
|
|
491
|
+
raise _shape("LINK_UNKNOWN_RELATION", f"unknown relation {rel!r}; relations: {', '.join(RELS)}", words=[rel])
|
|
410
492
|
for x in (src, dst):
|
|
411
493
|
if x not in s.nodes:
|
|
412
|
-
raise
|
|
494
|
+
raise _shape("LINK_NO_NODE", f"no node {x}", "name a node the record holds (its code or id)", [x])
|
|
413
495
|
st, dt = s.nodes[src]["type"], s.nodes[dst]["type"]
|
|
414
496
|
srcs, dsts = RELS[rel]
|
|
415
497
|
if st not in srcs or dt not in dsts:
|
|
@@ -417,8 +499,8 @@ class Graph:
|
|
|
417
499
|
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
500
|
hint = (f" — a {st} joins a {dt} with {', '.join(joins)}" if joins else "") + \
|
|
419
501
|
(f"; the other way round, a {dt} joins a {st} with {', '.join(back)}" if back else "")
|
|
420
|
-
raise
|
|
421
|
-
|
|
502
|
+
raise _shape("LINK_NOT_ALLOWED", f"{rel} can't join {st} → {dt} (allowed: {', '.join(sorted(srcs))} → "
|
|
503
|
+
f"{', '.join(sorted(dsts))}){hint}", words=[rel, st, dt])
|
|
422
504
|
if rel in SAME_TYPE and st != dt:
|
|
423
505
|
raise GraphError(f"{rel} joins two nodes of the same type")
|
|
424
506
|
if src == dst:
|
|
@@ -539,7 +621,13 @@ class Graph:
|
|
|
539
621
|
raise GraphError(f"unknown type {type!r}; types: {', '.join(TYPES)}")
|
|
540
622
|
if type == "review":
|
|
541
623
|
raise GraphError("use review() to record a review")
|
|
542
|
-
|
|
624
|
+
try:
|
|
625
|
+
body = clean_body(type, fields)
|
|
626
|
+
except GraphError as e:
|
|
627
|
+
refusals.record_refusal(self, author=author, op="create", inv=inv or "", node="",
|
|
628
|
+
body=fields if isinstance(fields, dict) else {},
|
|
629
|
+
problems=e.problems or [{"code": "SHAPE", "message": str(e)}], type="shape")
|
|
630
|
+
raise
|
|
543
631
|
links = [l for l in (links or []) if isinstance(l, dict)]
|
|
544
632
|
with self._lock:
|
|
545
633
|
try:
|
|
@@ -578,7 +666,15 @@ class Graph:
|
|
|
578
666
|
staged = {"id": nid, "type": type, "code": "", "inv": inv, "created_at": at, "created_by": who,
|
|
579
667
|
"v": 1, "body": body}
|
|
580
668
|
s.nodes[nid] = staged # staged for link checks; dropped if the write fails
|
|
581
|
-
|
|
669
|
+
specs, translated = [], []
|
|
670
|
+
for l in links:
|
|
671
|
+
a, rel, b = self._link_spec(nid, l)
|
|
672
|
+
given, (rel, note) = rel, self._translate(s, a, rel, b)
|
|
673
|
+
if note:
|
|
674
|
+
translated.append(note)
|
|
675
|
+
l["data"] = {**(l.get("data") or {}), "read_as": given} # what the author wrote
|
|
676
|
+
specs.append((a, rel, b))
|
|
677
|
+
oriented = [self._orient(s, *x) for x in specs]
|
|
582
678
|
pending = [(a, rel, b) for a, rel, b, _f in oriented]
|
|
583
679
|
flipped = [f"{s.nodes[a].get('code') or a} -{rel}-> {s.nodes[b].get('code') or b}"
|
|
584
680
|
for a, rel, b, f in oriented if f]
|
|
@@ -628,7 +724,7 @@ class Graph:
|
|
|
628
724
|
if rp:
|
|
629
725
|
found = [p.to_dict() for p in rp]
|
|
630
726
|
refusals.record_refusal(self, author=author, op="create", inv=inv, node="", body=body,
|
|
631
|
-
problems=found, support=[])
|
|
727
|
+
problems=found, support=[], type="result")
|
|
632
728
|
raise GraphError("result refused:\n- " + "\n- ".join(p.text() for p in rp), found)
|
|
633
729
|
if type in ("artifact", "source", "decision") and author not in P.curators:
|
|
634
730
|
np_ = profiles.node_issues(s, inv, type, body, pending) # the workflow's typed shapes
|
|
@@ -637,7 +733,7 @@ class Graph:
|
|
|
637
733
|
refusals.record_refusal(self, author=author, op="create", inv=inv, node="",
|
|
638
734
|
body={"statement": str(body.get("choice") or body.get("citation")
|
|
639
735
|
or body.get("path") or ""), "kind": type},
|
|
640
|
-
problems=found)
|
|
736
|
+
problems=found, type=type)
|
|
641
737
|
raise GraphError(f"{type} refused:\n- " + "\n- ".join(p.text() for p in np_), found)
|
|
642
738
|
if type == "result" and author not in P.reserved:
|
|
643
739
|
same = self._restated_by(s, body, inv, nid)
|
|
@@ -648,9 +744,8 @@ class Graph:
|
|
|
648
744
|
c = s.nodes[mine[0]]["code"] or mine[0]
|
|
649
745
|
found = [{"code": "RESULT_RESTATES", "message": f"this result repeats the numbers of your own "
|
|
650
746
|
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)
|
|
747
|
+
refusals.record_refusal(self, author=author, op="create", inv=inv, node="", body=body,
|
|
748
|
+
problems=found, type="result")
|
|
654
749
|
raise GraphError(f"result refused: it repeats the numbers of your own {c} — bind claims to "
|
|
655
750
|
f"{c} instead of writing it again; a genuine rerun is a run that replicates "
|
|
656
751
|
f"{c}'s run [RESULT_RESTATES]", found)
|
|
@@ -667,12 +762,19 @@ class Graph:
|
|
|
667
762
|
for (a, rel, b), l in zip(pending, links):
|
|
668
763
|
self._insert_edge(con, s, a, rel, b, l.get("data") or {}, l.get("why") or "", author, at)
|
|
669
764
|
self._transaction(write)
|
|
765
|
+
except GraphError as e:
|
|
766
|
+
if e.problems and str(e.problems[0].get("code", "")).startswith("LINK_"):
|
|
767
|
+
refusals.record_refusal(self, author=author, op="create", inv=inv or "", node="",
|
|
768
|
+
body=fields if isinstance(fields, dict) else {}, problems=e.problems,
|
|
769
|
+
type="link")
|
|
770
|
+
raise
|
|
670
771
|
finally:
|
|
671
772
|
# the staged node lives in the cached snapshot: a failed write drops it; after a commit the next
|
|
672
773
|
# read loads the committed rows over it
|
|
673
774
|
self._after_write()
|
|
674
775
|
out = self.get(nid)
|
|
675
776
|
notes = [f"link turned round to the direction the relation allows: {x}" for x in flipped]
|
|
777
|
+
notes += [f"link read as the relation that joins: {x}" for x in translated]
|
|
676
778
|
if auto_notes:
|
|
677
779
|
notes.append("bound by the record: " + ", ".join(auto_notes))
|
|
678
780
|
out["auto_bound"] = auto_notes
|
|
@@ -681,9 +783,10 @@ class Graph:
|
|
|
681
783
|
if out["state"].get("restates"):
|
|
682
784
|
out["restates"] = [self.get(r)["code"] for r in out["state"]["restates"]]
|
|
683
785
|
self._with_orders(out, author, inv)
|
|
684
|
-
if type
|
|
685
|
-
fixed = refusals.match_fix(self, author=author, inv=inv, node=nid,
|
|
686
|
-
|
|
786
|
+
if type in refusals.REPLAYABLE and author not in P.curators:
|
|
787
|
+
fixed = refusals.match_fix(self, author=author, inv=inv, node=nid, op="create",
|
|
788
|
+
statement=str(body.get("statement") or body.get("summary") or ""),
|
|
789
|
+
values=body.get("values") if type == "result" else None)
|
|
687
790
|
if fixed:
|
|
688
791
|
out["fixes_refusals"] = fixed
|
|
689
792
|
return out
|
|
@@ -835,7 +938,13 @@ class Graph:
|
|
|
835
938
|
if not self._may_revise(s, author, n):
|
|
836
939
|
raise GraphError(f"only {n['created_by']} (its author), a reviewer or the {P.operator} revises "
|
|
837
940
|
f"{n['code']}; challenge it, or propose a node that supersedes it")
|
|
838
|
-
|
|
941
|
+
try:
|
|
942
|
+
body = clean_body(n["type"], {**n["body"], **(fields or {})})
|
|
943
|
+
except GraphError as e:
|
|
944
|
+
refusals.record_refusal(self, author=author, op="revise", inv=n["inv"], node=node,
|
|
945
|
+
body=fields if isinstance(fields, dict) else {},
|
|
946
|
+
problems=e.problems or [{"code": "SHAPE", "message": str(e)}], type="shape")
|
|
947
|
+
raise
|
|
839
948
|
extra: list[str] = []
|
|
840
949
|
auto_notes: list[str] = []
|
|
841
950
|
if n["type"] == "claim":
|
|
@@ -876,9 +985,10 @@ class Graph:
|
|
|
876
985
|
out["notes"] = ["bound by the record: " + ", ".join(auto_notes)]
|
|
877
986
|
out["auto_bound"] = auto_notes
|
|
878
987
|
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"
|
|
988
|
+
if out["type"] in refusals.REPLAYABLE and author not in P.curators:
|
|
989
|
+
fixed = refusals.match_fix(self, author=author, inv=out["inv"], node=node, op="revise",
|
|
990
|
+
statement=str(out["body"].get("statement") or out["body"].get("summary") or ""),
|
|
991
|
+
values=out["body"].get("values") if out["type"] == "result" else None)
|
|
882
992
|
if fixed:
|
|
883
993
|
out["fixes_refusals"] = fixed
|
|
884
994
|
return out
|
|
@@ -889,14 +999,31 @@ class Graph:
|
|
|
889
999
|
with self._lock:
|
|
890
1000
|
try:
|
|
891
1001
|
s = self.snapshot()
|
|
1002
|
+
given, (rel, translated) = rel, self._translate(s, src, rel, dst)
|
|
1003
|
+
if translated:
|
|
1004
|
+
data = {**(data or {}), "read_as": given} # what the author wrote
|
|
892
1005
|
src, rel, dst, flipped = self._orient(s, src, rel, dst)
|
|
893
1006
|
if src in s.nodes:
|
|
894
1007
|
self._fixed_guard(s, author, src)
|
|
895
1008
|
eid = self._transaction(lambda con: self._insert_edge(con, s, src, rel, dst, data or {}, why, author,
|
|
896
1009
|
self.clock()))
|
|
1010
|
+
except GraphError as e:
|
|
1011
|
+
if e.problems and str(e.problems[0].get("code", "")).startswith("LINK_"):
|
|
1012
|
+
refusals.record_refusal(self, author=author, op="link", inv=(s.nodes.get(src) or {}).get("inv", ""),
|
|
1013
|
+
node="", body={"statement": f"{src} {rel} {dst}"}, problems=e.problems,
|
|
1014
|
+
type="link")
|
|
1015
|
+
raise
|
|
897
1016
|
finally:
|
|
898
1017
|
self._after_write()
|
|
899
|
-
|
|
1018
|
+
out = {"edge": eid, "src": src, "rel": rel, "dst": dst, "flipped": flipped}
|
|
1019
|
+
if translated:
|
|
1020
|
+
out["notes"] = [f"link read as the relation that joins: {translated}"]
|
|
1021
|
+
if author not in self.policy.curators:
|
|
1022
|
+
fixed = refusals.match_fix(self, author=author, inv=(s.nodes.get(src) or {}).get("inv", ""), node="",
|
|
1023
|
+
statement="", op="link", pair=(src, dst))
|
|
1024
|
+
if fixed:
|
|
1025
|
+
out["fixes_refusals"] = fixed
|
|
1026
|
+
return out
|
|
900
1027
|
|
|
901
1028
|
def retract(self, *, author: str, edge: int, why: str) -> dict:
|
|
902
1029
|
if len(str(why or "").strip()) < 10:
|