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.
Files changed (94) hide show
  1. {hypgraph-0.7.2 → hypgraph-0.7.3}/CHANGELOG.md +33 -0
  2. {hypgraph-0.7.2 → hypgraph-0.7.3}/PKG-INFO +1 -1
  3. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/spec.md +21 -8
  4. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/__init__.py +1 -1
  5. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/graph.py +160 -33
  6. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/refusals.py +131 -32
  7. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/schema.py +22 -3
  8. hypgraph-0.7.3/tests/test_link_translate.py +73 -0
  9. hypgraph-0.7.3/tests/test_option_lead.py +52 -0
  10. hypgraph-0.7.3/tests/test_refusal_results.py +89 -0
  11. hypgraph-0.7.3/tests/test_shape_refusals.py +91 -0
  12. {hypgraph-0.7.2 → hypgraph-0.7.3}/.gitignore +0 -0
  13. {hypgraph-0.7.2 → hypgraph-0.7.3}/AGENTS.md +0 -0
  14. {hypgraph-0.7.2 → hypgraph-0.7.3}/CITATION.cff +0 -0
  15. {hypgraph-0.7.2 → hypgraph-0.7.3}/LICENSE +0 -0
  16. {hypgraph-0.7.2 → hypgraph-0.7.3}/NOTICE +0 -0
  17. {hypgraph-0.7.2 → hypgraph-0.7.3}/README.md +0 -0
  18. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/PLAN.md +0 -0
  19. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  20. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  21. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/profile-workflows-plan.md +0 -0
  22. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/review-2026-10-24.md +0 -0
  23. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/site-plan.md +0 -0
  24. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/ui-plan.md +0 -0
  25. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/README.md +0 -0
  26. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/knowledge.md +0 -0
  27. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/optimization.md +0 -0
  28. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/scientific.md +0 -0
  29. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/software.md +0 -0
  30. {hypgraph-0.7.2 → hypgraph-0.7.3}/docs/workflows/trading.md +0 -0
  31. {hypgraph-0.7.2 → hypgraph-0.7.3}/pyproject.toml +0 -0
  32. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/bundle.py +0 -0
  34. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/checks.py +0 -0
  35. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/cli.py +0 -0
  36. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/coord.py +0 -0
  37. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/demos/__init__.py +0 -0
  38. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/demos/broad_street.py +0 -0
  39. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/derive.py +0 -0
  40. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/language.py +0 -0
  41. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/mcp.py +0 -0
  42. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/plan.py +0 -0
  43. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/policy.py +0 -0
  44. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/profiles.py +0 -0
  45. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/report_check.py +0 -0
  46. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/server.py +0 -0
  47. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/skill/SKILL.md +0 -0
  48. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/stats.py +0 -0
  49. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/store.py +0 -0
  50. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/__init__.py +0 -0
  51. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/atlas.css +0 -0
  52. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/atlas.js +0 -0
  53. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/hypgraph.css +0 -0
  54. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/hypgraph.js +0 -0
  55. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/hypgraph.svg +0 -0
  56. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/index.html +0 -0
  57. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  58. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  59. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  60. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  61. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  62. {hypgraph-0.7.2 → hypgraph-0.7.3}/src/hypgraph/workflow.py +0 -0
  63. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_accept_work_kind.py +0 -0
  64. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_bundle_telemetry.py +0 -0
  65. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_cancel_releases_lease.py +0 -0
  66. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_coord.py +0 -0
  67. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_d10.py +0 -0
  68. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_engine.py +0 -0
  69. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_engine_fixes.py +0 -0
  70. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_incremental.py +0 -0
  71. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_log_and_check.py +0 -0
  72. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_measure_questions.py +0 -0
  73. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_opposed_answers.py +0 -0
  74. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_order_detail.py +0 -0
  75. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_profiles.py +0 -0
  76. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_profiles_041.py +0 -0
  77. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_questions_not_assertions.py +0 -0
  78. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_refusal_friction.py +0 -0
  79. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_refusals.py +0 -0
  80. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_report_answers.py +0 -0
  81. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_rerunnable.py +0 -0
  82. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_restates_scale.py +0 -0
  83. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_server_auth.py +0 -0
  84. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_stats_not_backtests.py +0 -0
  85. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_stats_values.py +0 -0
  86. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_surfaces.py +0 -0
  87. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_telemetry_stream.py +0 -0
  88. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_ui_app.py +0 -0
  89. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_ui_engine.py +0 -0
  90. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_uncorrected_effect.py +0 -0
  91. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_unnamed_inputs.py +0 -0
  92. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_versions_not_figures.py +0 -0
  93. {hypgraph-0.7.2 → hypgraph-0.7.3}/tests/test_vocabulary_friction.py +0 -0
  94. {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.2
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), the sorted codes, and the
203
- problems (code, message, fix) it was refused with. `op` is `create` or `revise`; `node` is set for a revision.
204
- - `kind: fixed`: a later claim or revision by the same author in the same investigation, accepted within 24 hours,
205
- that says the same thing. "The same" means the same node for a revision; otherwise a Jaccard overlap ≥ 0.3 of the
206
- statements' content words. `ref` is the refusal it fixes.
207
-
208
- A report aggregates per author (refused, fixed, open, codes) and per rule.
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.2"
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 GraphError("fields must be an object")
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
- raise GraphError(f"{ntype} has no field(s) {unknown}; fields: {', '.join(spec)}")
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 GraphError(f"{ntype} needs {k}")
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 GraphError(f"{k} is longer than {n} characters")
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 GraphError(f"{k}: say it in a full sentence")
126
+ raise _shape("SHAPE_TOO_SHORT", f"{k}: say it in a full sentence", words=[k])
71
127
  elif kind.startswith("enum:"):
72
- v = SYNONYMS.get((ntype, k), {}).get(str(v).strip().lower().replace(" ", "_").replace("-", "_"), str(v).strip())
73
- if str(v) not in kind[5:].split("|"):
74
- raise GraphError(f"{k} must be one of {kind[5:].replace('|', ', ')}")
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 GraphError(f"{k} must be a list of at most {n} items")
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 GraphError(f"{k} must be an object (at most {n} characters)")
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 GraphError(f"{k} must be a finite number")
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 GraphError(f"{k} is too long")
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 GraphError(f"unknown relation {rel!r}; relations: {', '.join(RELS)}")
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 GraphError(f"no node {x}")
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 GraphError(f"{rel} can't join {st} → {dt} (allowed: {', '.join(sorted(srcs))} → "
421
- f"{', '.join(sorted(dsts))}){hint}")
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
- body = clean_body(type, fields)
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
- oriented = [self._orient(s, *self._link_spec(nid, l)) for l in links]
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
- body={"statement": body.get("summary", ""), "kind": "result"},
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 == "claim" and author not in P.curators:
685
- fixed = refusals.match_fix(self, author=author, inv=inv, node=nid, statement=body.get("statement", ""),
686
- op="create")
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
- body = clean_body(n["type"], {**n["body"], **(fields or {})})
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"] == "claim" and author not in P.curators:
880
- fixed = refusals.match_fix(self, author=author, inv=out["inv"], node=node,
881
- statement=out["body"].get("statement", ""), op="revise")
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
- return {"edge": eid, "src": src, "rel": rel, "dst": dst, "flipped": flipped}
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: