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.
Files changed (96) hide show
  1. {hypgraph-0.7.2 → hypgraph-0.7.4}/CHANGELOG.md +49 -1
  2. {hypgraph-0.7.2 → hypgraph-0.7.4}/PKG-INFO +1 -1
  3. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/spec.md +21 -8
  4. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/__init__.py +1 -1
  5. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/derive.py +19 -1
  6. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/graph.py +171 -33
  7. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/refusals.py +131 -32
  8. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/schema.py +41 -3
  9. hypgraph-0.7.4/tests/test_bound_support.py +55 -0
  10. hypgraph-0.7.4/tests/test_link_translate.py +73 -0
  11. hypgraph-0.7.4/tests/test_option_lead.py +52 -0
  12. hypgraph-0.7.4/tests/test_refusal_results.py +89 -0
  13. hypgraph-0.7.4/tests/test_relation_words.py +38 -0
  14. hypgraph-0.7.4/tests/test_shape_refusals.py +91 -0
  15. {hypgraph-0.7.2 → hypgraph-0.7.4}/.gitignore +0 -0
  16. {hypgraph-0.7.2 → hypgraph-0.7.4}/AGENTS.md +0 -0
  17. {hypgraph-0.7.2 → hypgraph-0.7.4}/CITATION.cff +0 -0
  18. {hypgraph-0.7.2 → hypgraph-0.7.4}/LICENSE +0 -0
  19. {hypgraph-0.7.2 → hypgraph-0.7.4}/NOTICE +0 -0
  20. {hypgraph-0.7.2 → hypgraph-0.7.4}/README.md +0 -0
  21. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/PLAN.md +0 -0
  22. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  23. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  24. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/profile-workflows-plan.md +0 -0
  25. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/review-2026-10-24.md +0 -0
  26. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/site-plan.md +0 -0
  27. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/ui-plan.md +0 -0
  28. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/README.md +0 -0
  29. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/knowledge.md +0 -0
  30. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/optimization.md +0 -0
  31. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/scientific.md +0 -0
  32. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/software.md +0 -0
  33. {hypgraph-0.7.2 → hypgraph-0.7.4}/docs/workflows/trading.md +0 -0
  34. {hypgraph-0.7.2 → hypgraph-0.7.4}/pyproject.toml +0 -0
  35. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/__main__.py +0 -0
  36. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/bundle.py +0 -0
  37. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/checks.py +0 -0
  38. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/cli.py +0 -0
  39. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/coord.py +0 -0
  40. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/demos/__init__.py +0 -0
  41. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/demos/broad_street.py +0 -0
  42. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/language.py +0 -0
  43. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/mcp.py +0 -0
  44. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/plan.py +0 -0
  45. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/policy.py +0 -0
  46. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/profiles.py +0 -0
  47. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/report_check.py +0 -0
  48. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/server.py +0 -0
  49. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/skill/SKILL.md +0 -0
  50. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/stats.py +0 -0
  51. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/store.py +0 -0
  52. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/__init__.py +0 -0
  53. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/atlas.css +0 -0
  54. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/atlas.js +0 -0
  55. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/hypgraph.css +0 -0
  56. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/hypgraph.js +0 -0
  57. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/hypgraph.svg +0 -0
  58. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/index.html +0 -0
  59. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  60. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  61. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  62. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  63. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  64. {hypgraph-0.7.2 → hypgraph-0.7.4}/src/hypgraph/workflow.py +0 -0
  65. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_accept_work_kind.py +0 -0
  66. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_bundle_telemetry.py +0 -0
  67. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_cancel_releases_lease.py +0 -0
  68. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_coord.py +0 -0
  69. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_d10.py +0 -0
  70. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_engine.py +0 -0
  71. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_engine_fixes.py +0 -0
  72. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_incremental.py +0 -0
  73. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_log_and_check.py +0 -0
  74. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_measure_questions.py +0 -0
  75. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_opposed_answers.py +0 -0
  76. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_order_detail.py +0 -0
  77. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_profiles.py +0 -0
  78. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_profiles_041.py +0 -0
  79. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_questions_not_assertions.py +0 -0
  80. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_refusal_friction.py +0 -0
  81. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_refusals.py +0 -0
  82. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_report_answers.py +0 -0
  83. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_rerunnable.py +0 -0
  84. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_restates_scale.py +0 -0
  85. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_server_auth.py +0 -0
  86. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_stats_not_backtests.py +0 -0
  87. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_stats_values.py +0 -0
  88. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_surfaces.py +0 -0
  89. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_telemetry_stream.py +0 -0
  90. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_ui_app.py +0 -0
  91. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_ui_engine.py +0 -0
  92. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_uncorrected_effect.py +0 -0
  93. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_unnamed_inputs.py +0 -0
  94. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_versions_not_figures.py +0 -0
  95. {hypgraph-0.7.2 → hypgraph-0.7.4}/tests/test_vocabulary_friction.py +0 -0
  96. {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.2
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), 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.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
- return [e for e in s.inn.get(nid, []) if e["rel"] == "supports"]
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 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,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 GraphError(f"unknown relation {rel!r}; relations: {', '.join(RELS)}")
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 GraphError(f"no node {x}")
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 GraphError(f"{rel} can't join {st} → {dt} (allowed: {', '.join(sorted(srcs))} → "
421
- f"{', '.join(sorted(dsts))}){hint}")
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
- body = clean_body(type, fields)
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
- oriented = [self._orient(s, *self._link_spec(nid, l)) for l in links]
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
- body={"statement": body.get("summary", ""), "kind": "result"},
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 == "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")
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
- body = clean_body(n["type"], {**n["body"], **(fields or {})})
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"] == "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")
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
- return {"edge": eid, "src": src, "rel": rel, "dst": dst, "flipped": flipped}
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: