hypgraph 0.5.0__tar.gz → 0.5.1__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 (72) hide show
  1. {hypgraph-0.5.0 → hypgraph-0.5.1}/CHANGELOG.md +21 -0
  2. {hypgraph-0.5.0 → hypgraph-0.5.1}/PKG-INFO +1 -1
  3. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/profile-workflows-plan.md +5 -1
  4. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/workflows/optimization.md +2 -1
  5. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/workflows/software.md +5 -0
  6. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/__init__.py +1 -1
  7. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/graph.py +45 -1
  8. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/plan.py +2 -1
  9. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/profiles.py +1 -0
  10. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/workflow.py +91 -11
  11. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_workflows.py +90 -0
  12. {hypgraph-0.5.0 → hypgraph-0.5.1}/.gitignore +0 -0
  13. {hypgraph-0.5.0 → hypgraph-0.5.1}/AGENTS.md +0 -0
  14. {hypgraph-0.5.0 → hypgraph-0.5.1}/CITATION.cff +0 -0
  15. {hypgraph-0.5.0 → hypgraph-0.5.1}/LICENSE +0 -0
  16. {hypgraph-0.5.0 → hypgraph-0.5.1}/NOTICE +0 -0
  17. {hypgraph-0.5.0 → hypgraph-0.5.1}/README.md +0 -0
  18. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/PLAN.md +0 -0
  19. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  20. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  21. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/review-2026-10-24.md +0 -0
  22. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/spec.md +0 -0
  23. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/workflows/README.md +0 -0
  24. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/workflows/knowledge.md +0 -0
  25. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/workflows/scientific.md +0 -0
  26. {hypgraph-0.5.0 → hypgraph-0.5.1}/docs/workflows/trading.md +0 -0
  27. {hypgraph-0.5.0 → hypgraph-0.5.1}/pyproject.toml +0 -0
  28. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/__main__.py +0 -0
  29. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/bundle.py +0 -0
  30. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/checks.py +0 -0
  31. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/cli.py +0 -0
  32. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/coord.py +0 -0
  33. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/demos/__init__.py +0 -0
  34. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/demos/broad_street.py +0 -0
  35. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/derive.py +0 -0
  36. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/language.py +0 -0
  37. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/mcp.py +0 -0
  38. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/policy.py +0 -0
  39. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/refusals.py +0 -0
  40. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/report_check.py +0 -0
  41. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/schema.py +0 -0
  42. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/server.py +0 -0
  43. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/skill/SKILL.md +0 -0
  44. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/stats.py +0 -0
  45. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/store.py +0 -0
  46. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/__init__.py +0 -0
  47. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/atlas.css +0 -0
  48. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/atlas.js +0 -0
  49. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/hypgraph.svg +0 -0
  50. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/index.html +0 -0
  51. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  52. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  53. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  54. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  55. {hypgraph-0.5.0 → hypgraph-0.5.1}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  56. {hypgraph-0.5.0 → hypgraph-0.5.1}/studies/harmony/tests/test_suite.py +0 -0
  57. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_cancel_releases_lease.py +0 -0
  58. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_coord.py +0 -0
  59. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_d10.py +0 -0
  60. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_engine.py +0 -0
  61. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_engine_fixes.py +0 -0
  62. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_incremental.py +0 -0
  63. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_log_and_check.py +0 -0
  64. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_order_detail.py +0 -0
  65. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_profiles.py +0 -0
  66. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_profiles_041.py +0 -0
  67. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_questions_not_assertions.py +0 -0
  68. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_refusals.py +0 -0
  69. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_server_auth.py +0 -0
  70. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_stats_values.py +0 -0
  71. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_surfaces.py +0 -0
  72. {hypgraph-0.5.0 → hypgraph-0.5.1}/tests/test_vocabulary_friction.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 — 2026-10-26
4
+
5
+ What wiring HypSwarm's scheduler to the stages (P2) found the workflows had no honest ending or no right answer for:
6
+ - **Declining a thesis is a decision.** A decision `kind=no_position` based on the claim meets `positioned` and
7
+ `position_sized` ("1 declined"); only `kind=position` needs size, stop, review date and `paper=true`.
8
+ - **An honest "it can't be answered" settles a question whatever its stage.** A checked claim of kind
9
+ `inconclusive` or `exclusion` that answers the question (no source says it; too few trades; infeasible) completes
10
+ it; `stage()` reports `settled_by` and `settled_how`. The claim still needs its evidence — the search's own
11
+ result ("read 3 of 3 pages; 0 mention it") is what it derives from.
12
+ - **A verified failure settles a software requirement.** A checked claim resting on an instrument-recorded
13
+ *negative* result ("1 of 2 checks pass", re-run by the instrument) completes the question; `tests_pass_current`
14
+ stays unmet and `requirements_verified_current` says so.
15
+ - **The same bytes, registered again, are one node that learns.** A second `create` of an artifact with the same
16
+ sha adds the links given now (`part_of` a question, `supersedes`) and, when the node was untyped, its `kind` /
17
+ `meta` / `path` as a new version. Before, the task's own file silently swallowed the diff registered on it.
18
+ - **A decision that settles a work order is the harness's, not the question's** (`decided` ignores it).
19
+ - **optimization gains a `checked` stage** (the verifier checks the claim after the result): a question settles on a
20
+ checked claim, so the workflow had to ask for one.
21
+ - A review order's target predates the order; `reviewed` / `result_reviewed` now ask that the *review* be new.
22
+ - `report()` names experiments by aim and decisions by choice.
23
+
3
24
  ## 0.5.0 — 2026-10-26: profiles carry their discipline's workflow
4
25
 
5
26
  Each discipline's method, as the record enforces it — one page each in `docs/workflows/`, taken from the method
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.5.0
3
+ Version: 0.5.1
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/
@@ -91,7 +91,11 @@ needs a migration story. P0 is a day and decides whether P1 is right. P3 is the
91
91
  `tests/test_workflows.py`; CHANGELOG). The schema change turned out additive (fields and relation endpoints), so
92
92
  no migration: `@1` records derive exactly as before, the `@2` profiles opt in. Left for later phases, each named in
93
93
  its spec: per-workflow evidence rank (documented, not derived), the knowledge re-fetch check, CI as the software
94
- instrument, mechanical feasibility recomputation (P3). P2 (HypSwarm issues orders from stages) is next.
94
+ instrument, mechanical feasibility recomputation (P3). **P2 built in HypSwarm 0.1.0a4** (`scheduler.py`: stage
95
+ orders, review on the node the previous gate produced, the record's word on done; the examples' scripted models walk
96
+ the stages; the discovery stub run through its stages). P2 fed back into hypgraph 0.5.1: honest endings the stages
97
+ lacked (a declined thesis, an inconclusive answer, a verified failure), artifacts that learn their kind on
98
+ re-registration, optimization's `checked` stage. P3 is next.
95
99
 
96
100
  ## 4. What stays as it is, on purpose
97
101
 
@@ -20,7 +20,8 @@ model is a hypothesis about the problem and can be wrong independently of the so
20
20
  | 4 | solved | run `executes` the experiment, `uses` the instance; result with `objective`, `feasible`, `bound`, `gap`, `runtime_s`, `solution` artifact | `feasible_bounded` (today) | solver, instrument |
21
21
  | 5 | verified | review by the verifier: the solution satisfies the constraints of the **instance artifact** (recomputed, not trusted) | `reviewed` with method naming the check | verifier |
22
22
  | 6 | claimed | claim "best found, gap g" or "optimal" with the bound; `OPTIMAL_WITHOUT_BOUND` (today) | `claim_made` | solver |
23
- | 7 | recommended | decision `based_on` the claim: the incumbent to use, with the sensitivity note | `decided` **new** | lead |
23
+ | 7 | checked | the verifier checks the claim's wording against the result (a question settles on a checked claim) | `reviewed` | verifier |
24
+ | 8 | recommended | decision `based_on` the claim: the incumbent to use, with the sensitivity note | `decided` **new** (optional) | lead |
24
25
 
25
26
  Infeasibility is a settled outcome: a result with `feasible: false` and a claim of `kind=exclusion` ("no feasible
26
27
  assignment exists: total capacity 7 < 8 customers") settles the question. Today that path trips
@@ -76,3 +76,8 @@ superseded heads, listed as superseded); open requirements.
76
76
  reads, so a CI reproducer only has to file a lab run that `uses` the diff.
77
77
 
78
78
  Also decided: `REVIEW_BY_AUTHOR` covers the diff's author (built); `supersedes` joins artifacts (built).
79
+
80
+ **Verified failure (0.5.1).** When the instrument's run on the current head records a negative result ("1 of 2
81
+ checks pass"), `tests_pass_current` can never be met; a checked claim resting on that result settles the
82
+ requirement as a verified failure (`stage()` says so), and `requirements_verified_current` stays unmet. The record
83
+ holds why the change did not ship.
@@ -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.5.0"
19
+ __version__ = "0.5.1"
20
20
 
21
21
  from .bundle import verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -558,7 +558,7 @@ class Graph:
558
558
  if type == "artifact":
559
559
  node_id = "a-" + str(body["sha256"]).lower()[:16]
560
560
  if node_id in s.nodes:
561
- return self.get(node_id)
561
+ return self._absorb_artifact(s, node_id, body, links, author, at=at)
562
562
  nid = node_id or ("inv-" + secrets.token_hex(4) if type == "investigation"
563
563
  else "n" + secrets.token_hex(6))
564
564
  if nid in s.nodes:
@@ -773,6 +773,50 @@ class Graph:
773
773
  if lines:
774
774
  out["orders"] = lines
775
775
 
776
+ def _absorb_artifact(self, s: D.Snap, nid: str, body: dict, links: list, author: str, *, at: str = "") -> dict:
777
+ """The same bytes are one node. A second registration adds what the first lacked: the links given now
778
+ (part_of a question, supersedes an older file) and, when the node was untyped, its kind / meta / path — a
779
+ classification of the bytes, by anyone, as a new version (the bytes never change). Called inside create()'s
780
+ lock; the caller's finally refreshes the snapshot."""
781
+ n = s.nodes[nid]
782
+ at = self._when(author, at)
783
+ have = {(e["rel"], e["dst"]) for e in s.out.get(nid, [])} | {(e["rel"], e["src"]) for e in s.inn.get(nid, [])}
784
+ oriented = [self._orient(s, *self._link_spec(nid, l)) for l in links]
785
+ pending = [(a, rel, b, l) for (a, rel, b, _f), l in zip(oriented, links)
786
+ if (rel, b if a == nid else a) not in have]
787
+ for a, rel, b, _l in pending:
788
+ self._check_link(s, a, rel, b)
789
+ merged = None
790
+ if not n["body"].get("kind") and body.get("kind"):
791
+ merged = clean_body("artifact", {**n["body"], **{k: v for k, v in body.items()
792
+ if k in ("kind", "meta", "path", "format", "size") and v}})
793
+ if author not in self.policy.curators:
794
+ np_ = profiles.node_issues(s, n["inv"], "artifact", merged, [(a, rel, b) for a, rel, b, _l in pending])
795
+ if np_:
796
+ raise GraphError("artifact refused:\n- " + "\n- ".join(p.text() for p in np_), [p.to_dict() for p in np_])
797
+ if not pending and merged is None:
798
+ return self.get(nid)
799
+
800
+ def write(con):
801
+ if merged is not None:
802
+ v = con.execute("SELECT MAX(v) FROM versions WHERE node=?", (nid,)).fetchone()[0] + 1
803
+ sha = self._version_row(con, nid, v, at, author, f"typed as {merged.get('kind')}", merged)
804
+ self._event(con, author, "revise", nid, {"v": v, "reason": f"typed as {merged.get('kind')}",
805
+ "body_sha": sha}, at)
806
+ for a, rel, b, l in pending:
807
+ self._insert_edge(con, s, a, rel, b, l.get("data") or {}, l.get("why") or "", author, at)
808
+ self._transaction(write)
809
+ self._after_write()
810
+ out = self.get(nid)
811
+ notes = ["the same bytes were already registered: one node"]
812
+ if merged is not None:
813
+ notes.append(f"typed as {merged.get('kind')} (v{out['v']})")
814
+ if pending:
815
+ notes.append("linked: " + ", ".join(f"{rel} {s.nodes[b if a == nid else a].get('code') or b}"
816
+ for a, rel, b, _l in pending))
817
+ out["notes"] = notes
818
+ return out
819
+
776
820
  def _may_revise(self, s: D.Snap, author: str, n: dict) -> bool:
777
821
  return author in {n["created_by"]} | self.policy.curators or author in D.board(s, n["id"])
778
822
 
@@ -229,7 +229,8 @@ def _brief_min(s: D.Snap, i: str) -> dict:
229
229
  m = s.nodes[i]
230
230
  b = m["body"]
231
231
  return {"id": i, "code": m.get("code", ""), "type": m["type"],
232
- "title": str(b.get("title") or b.get("statement") or b.get("text") or b.get("summary") or i)[:160]}
232
+ "title": str(b.get("title") or b.get("statement") or b.get("text") or b.get("summary") or b.get("aim")
233
+ or b.get("choice") or b.get("citation") or b.get("name") or b.get("path") or i)[:160]}
233
234
 
234
235
 
235
236
  def report(g: "Graph", inv: str) -> dict:
@@ -347,6 +347,7 @@ OPTIMIZATION2 = _add(Profile(
347
347
  _S("solved", "a result: objective, feasible, bound / gap (or infeasible)", "solved", "solver, instrument"),
348
348
  _S("verified", "a verifier recomputed feasibility and checked the result", "result_reviewed", "verifier"),
349
349
  _S("claimed", "a claim: best found with its gap, or optimal with its bound", "claim_made", "solver"),
350
+ _S("checked", "the verifier checked the claim's wording against the result", "reviewed", "verifier"),
350
351
  _S("recommended", "a decision: the incumbent to use, with its sensitivity", "decided", "lead", optional=True)),
351
352
  invalidates=("uses",),
352
353
  artifacts={"instance": (), "solution": ("objective", "feasible"), "model": ("solver",)},
@@ -27,6 +27,7 @@ from . import language as L
27
27
  from .checks import Problem
28
28
  from .profiles import (_EDGE, _OPTIMAL, _PASSES, Profile, _cites_source, _has, _instrument_backed, _lab_positive,
29
29
  _live, _producing_run, _vals, profile_of)
30
+ from .schema import POSITIVE
30
31
 
31
32
  _LIVE_ORDER = re.compile(r"\b(live (order|trade|position)|market order|limit order|place[ds]? (an? )?order|"
32
33
  r"execute[sd]? (a |the )?trade|sen[dt] (it |the order )?to (the )?(exchange|broker|venue)|"
@@ -210,9 +211,21 @@ def g_claim_made(sc: Scope, now: str) -> dict:
210
211
  return _g(bool(cs), cs, f"no live claim answers {sc.code(sc.root)}")
211
212
 
212
213
 
214
+ def _checked_since(sc: Scope, nid: str) -> bool:
215
+ """Checked — and, under an order, by a review written since the order (the node itself may well predate it:
216
+ a review order's target is the claim or result it is to review)."""
217
+ s = sc.s
218
+ if (D.state(s, nid).get("review") or {}).get("state") != "checked":
219
+ return False
220
+ if not sc.since:
221
+ return True
222
+ return any(e["rel"] == "reviews" and s.nodes[e["src"]]["created_at"] >= sc.since
223
+ and s.nodes[e["src"]]["body"].get("verdict") in POSITIVE for e in s.inn.get(nid, []))
224
+
225
+
213
226
  def g_reviewed(sc: Scope, now: str) -> dict:
214
227
  s = sc.s
215
- checked = [c for c in sc.fresh(sc.claims) if (D.state(s, c).get("review") or {}).get("state") == "checked"]
228
+ checked = [c for c in sc.claims if _live(s, c) and _checked_since(sc, c)]
216
229
  fresh = [c for c in checked if not D.stale(s, c)]
217
230
  if fresh:
218
231
  return _g(True, fresh, "")
@@ -224,7 +237,8 @@ def g_reviewed(sc: Scope, now: str) -> dict:
224
237
 
225
238
 
226
239
  def g_decided(sc: Scope, now: str) -> dict:
227
- ds = sc.fresh(sc.decisions)
240
+ # a decision that settles a work order is the harness accepting work, not a decision on the question
241
+ ds = [d for d in sc.fresh(sc.decisions) if not any(e["rel"] == "settles" for e in sc.s.out.get(d, []))]
228
242
  return _g(bool(ds), ds, f"no decision is based on a claim or result on {sc.code(sc.root)}")
229
243
 
230
244
 
@@ -238,8 +252,9 @@ def _current_diffs(sc: Scope, kind: str) -> tuple[list[str], list[str]]:
238
252
 
239
253
 
240
254
  def g_diff_registered(sc: Scope, now: str) -> dict:
255
+ # the question's current diff is a state, not work since the order: a change registered before the order (the
256
+ # task's own files are artifacts from the start, and the same bytes are one node) meets it
241
257
  live, _old = _current_diffs(sc, "diff")
242
- live = [a for a in live if not sc.since or sc.s.nodes[a]["created_at"] >= sc.since]
243
258
  return _g(bool(live), live[:1], f"no live artifact kind=diff (meta: base_sha, head_sha, files) part_of "
244
259
  f"{sc.code(sc.root)}")
245
260
 
@@ -331,12 +346,18 @@ def _positions(sc: Scope) -> list[str]:
331
346
  return [d for d in sc.decisions if _live(sc.s, d) and (sc.s.nodes[d]["body"].get("kind") or "") == "position"]
332
347
 
333
348
 
349
+ def _no_positions(sc: Scope) -> list[str]:
350
+ """Decisions not to trade (kind=no_position): a decision too — the thesis was judged and declined."""
351
+ return [d for d in sc.decisions if _live(sc.s, d) and (sc.s.nodes[d]["body"].get("kind") or "") == "no_position"]
352
+
353
+
334
354
  def g_positioned(sc: Scope, now: str) -> dict:
335
355
  s = sc.s
336
356
  ds = [d for d in _positions(sc) if (not sc.since or s.nodes[d]["created_at"] >= sc.since)
337
357
  and not _position_missing(s.nodes[d]["body"])]
338
- return _g(bool(ds), ds, f"no decision kind=position (size, stop, review_date, paper=true) based on a claim on "
339
- f"{sc.code(sc.root)}")
358
+ ds += [d for d in _no_positions(sc) if not sc.since or s.nodes[d]["created_at"] >= sc.since]
359
+ return _g(bool(ds), ds, f"no decision based on a claim on {sc.code(sc.root)}: kind=position (size, stop, "
360
+ "review_date, paper=true), or kind=no_position when the thesis is declined")
340
361
 
341
362
 
342
363
  def _past_review(sc: Scope, d: str, now: str) -> bool:
@@ -378,7 +399,7 @@ def g_solved(sc: Scope, now: str) -> dict:
378
399
 
379
400
  def g_result_reviewed(sc: Scope, now: str) -> dict:
380
401
  s = sc.s
381
- rs = [r for r in sc.fresh(sc.results) if (D.state(s, r).get("review") or {}).get("state") == "checked"]
402
+ rs = [r for r in sc.results if _live(s, r) and _checked_since(sc, r)]
382
403
  return _g(bool(rs), rs, f"no result on {sc.code(sc.root)} is checked by a reviewer")
383
404
 
384
405
 
@@ -401,6 +422,25 @@ def evaluate_predicate(p: str, s: D.Snap, t: str, at: str, inv: str, who: str =
401
422
  return GATES[p](Scope(s, t, since=at, who=who), s.now)
402
423
 
403
424
 
425
+ def _claim_results(s: D.Snap, c: str) -> list[str]:
426
+ rs = [e["src"] for e in s.inn.get(c, []) if e["rel"] == "supports" and s.nodes[e["src"]]["type"] == "result"]
427
+ rs += [e["dst"] for e in s.out.get(c, []) if e["rel"] == "derived_from" and s.nodes[e["dst"]]["type"] == "result"]
428
+ return _uniq(rs)
429
+
430
+
431
+ def _lab_negative(s: D.Snap, r: str) -> bool:
432
+ """The instrument stands behind a failure: the result's own lab run, or a live lab run replicating the run that
433
+ produced it, has a live result with outcome negative."""
434
+ run = _producing_run(s, r)
435
+ if not run:
436
+ return False
437
+ labs = [run] if s.nodes[run]["body"].get("kind") == "lab" else []
438
+ labs += [e["src"] for e in s.inn.get(run, []) if e["rel"] == "replicates" and s.nodes[e["src"]]["body"].get("kind") == "lab"]
439
+ return any(_live(s, lab) and any(s.nodes[e["dst"]]["type"] == "result" and s.nodes[e["dst"]]["body"].get("outcome") == "negative"
440
+ and _live(s, e["dst"]) for e in s.out.get(lab, []) if e["rel"] == "produced")
441
+ for lab in labs)
442
+
443
+
404
444
  # ── the derived stage ──────────────────────────────────────────────
405
445
 
406
446
  def stage_of(s: D.Snap, t: str, now: str = "") -> dict:
@@ -422,6 +462,23 @@ def stage_of(s: D.Snap, t: str, now: str = "") -> dict:
422
462
  if stage is None and not r["met"] and not st.optional:
423
463
  stage, nxt = (i, st.name), r["why"]
424
464
  stages = p.workflow.stages
465
+ # An honest "it can't be answered" ends a question whatever its stage: a checked claim of kind inconclusive
466
+ # (or exclusion) that answers it — no source found, too few trades, infeasible. The record holds the reason.
467
+ checked = [c for c in sc.claims if _live(s, c) and not D.stale(s, c)
468
+ and (D.state(s, c).get("review") or {}).get("state") == "checked"]
469
+ settled = [(c, f"{s.nodes[c]['body'].get('kind')} answer, checked") for c in checked
470
+ if (s.nodes[c]["body"].get("kind") or "finding") in ("inconclusive", "exclusion")]
471
+ # A verified failure settles a requirement too: a checked claim resting on an instrument-recorded negative
472
+ # result ("1 of 2 checks pass", re-run by the instrument) — the gate that asks for a passing run can never be met,
473
+ # and the record now holds why. Only workflows whose gate is an instrument pass have this ending.
474
+ if any(st.gate == "tests_pass_current" for st in stages):
475
+ settled += [(c, "verified failure: an instrument-recorded negative result, checked") for c in checked
476
+ if any(_lab_negative(s, r) for r in _claim_results(s, c))]
477
+ if stage is not None and settled:
478
+ rv = next((i for i, st in enumerate(stages) if st.gate == "reviewed"), len(stages) - 1)
479
+ return {**base, "workflow": True, "stage": stages[rv].name, "index": rv + 1, "of": len(stages),
480
+ "complete": True, "next": "", "gates": gates, "settled_by": sc.code(settled[0][0]),
481
+ "settled_how": settled[0][1]}
425
482
  if stage is None: # every required gate met: the stage is the last one whose gate is
426
483
  last = max(i for i, gt in enumerate(gates) if gt["met"])
427
484
  return {**base, "workflow": True, "stage": stages[last].name, "index": last + 1, "of": len(stages),
@@ -541,6 +598,25 @@ def review_refusal(p: Profile, s: D.Snap, target: str, author: str) -> str:
541
598
 
542
599
  # ── completion criteria the workflows add ──────────────────────────
543
600
 
601
+ def _backed_fresh(s: D.Snap, r: str) -> bool:
602
+ """A result the instrument stands behind on the current artifacts: its own lab run, or a live lab run that
603
+ replicates the run that produced it with a positive result — and every artifact that lab run used is live, and
604
+ the result itself is not stale."""
605
+ if D.stale(s, r):
606
+ return False
607
+ run = _producing_run(s, r)
608
+ if not run:
609
+ return False
610
+ labs = [run] if s.nodes[run]["body"].get("kind") == "lab" else []
611
+ labs += [e["src"] for e in s.inn.get(run, []) if e["rel"] == "replicates" and s.nodes[e["src"]]["body"].get("kind") == "lab"]
612
+ for lab in labs:
613
+ if not _live(s, lab) or not _positive_results(s, lab):
614
+ continue
615
+ if all(D.lifecycle(s, a) == "active" for a in _uses(s, lab)):
616
+ return True
617
+ return False
618
+
619
+
544
620
  def _checked_claims(s: D.Snap, q: str) -> list[str]:
545
621
  return [e["src"] for e in s.inn.get(q, []) if e["rel"] == "answers" and s.nodes[e["src"]]["type"] == "claim"
546
622
  and _live(s, e["src"]) and (D.state(s, e["src"]).get("review") or {}).get("state") == "checked"]
@@ -557,7 +633,7 @@ def criterion(name: str, s: D.Snap, inv: str) -> dict:
557
633
  for c in _checked_claims(s, q): # any checked answer resting on a fresh instrument run
558
634
  rs = [e["src"] for e in s.inn.get(c, []) if e["rel"] == "supports"] + \
559
635
  [e["dst"] for e in s.out.get(c, []) if e["rel"] == "derived_from"]
560
- if any(s.nodes[r]["type"] == "result" and _lab_positive(s, r) and not D.stale(s, r) for r in rs):
636
+ if any(s.nodes[r]["type"] == "result" and _backed_fresh(s, r) for r in rs):
561
637
  ok = True
562
638
  break
563
639
  if not ok:
@@ -566,10 +642,14 @@ def criterion(name: str, s: D.Snap, inv: str) -> dict:
566
642
  "current head" if qs and not bad else "no current instrument-run evidence for: " + ", ".join(bad)}
567
643
  if name == "position_sized":
568
644
  ds = [i for i, n in s.nodes.items() if n["inv"] == inv and n["type"] == "decision" and _live(s, i)
569
- and (n["body"].get("kind") or "") == "position" and not _position_missing(n["body"])
570
- and any(e["rel"] == "based_on" for e in s.out.get(i, []))]
571
- return {"met": bool(ds), "why": f"{len(ds)} paper position(s) with size, stop and review date" if ds else
572
- "no decision kind=position with size, stop, review_date and paper=true based on a claim"}
645
+ and any(e["rel"] == "based_on" for e in s.out.get(i, []))
646
+ and (((n["body"].get("kind") or "") == "position" and not _position_missing(n["body"]))
647
+ or (n["body"].get("kind") or "") == "no_position")]
648
+ taken = sum(1 for d in ds if s.nodes[d]["body"].get("kind") == "position")
649
+ return {"met": bool(ds), "why": f"{taken} paper position(s) with size, stop and review date, "
650
+ f"{len(ds) - taken} declined" if ds else
651
+ "no decision based on a claim: kind=position with size, stop, review_date and paper=true, or "
652
+ "kind=no_position"}
573
653
  if name == "nothing_past_review":
574
654
  late = []
575
655
  for i, n in s.nodes.items():
@@ -361,3 +361,93 @@ def test_server_serves_stages():
361
361
  assert st == 200 and body["code"] == "Q1" and [x["gate"] for x in body["gates"]][:3] == ["stated", "diff_registered", "tests_pass_current"]
362
362
  st, body = dispatch(g, "GET", "/profiles")
363
363
  assert st == 200 and body["trading@2"]["workflow"]["decisions"] == {"position": ["size", "stop", "review_date", "paper"]}
364
+
365
+
366
+ # ── 0.5.1: declining a thesis is a decision; an honest "can't be answered" settles a question ──
367
+
368
+ def test_no_position_is_a_decision():
369
+ g, inv, q = _g("trading@2", reviewers=("risk",))
370
+ h = g.create(author="quant", type="hypothesis", inv=inv, fields={"statement": "Momentum over 12 months persists for a month.",
371
+ "predictions": ["the top decile outperforms after costs out of sample"]}, links=[{"rel": "answers", "to": q}])["id"]
372
+ x = g.create(author="quant", type="experiment", inv=inv, fields={"aim": "Backtest 12-1 momentum.",
373
+ "protocol": "In sample 2015-2021, out of sample 2022-2024; fees 10 bp, slippage 5 bp; 24 configurations.",
374
+ "success_criteria": "positive out-of-sample return after costs"},
375
+ links=[{"rel": "tests", "to": h, "data": {"expected": "positive oos return after costs"}}])["id"]
376
+ rs = _bt(g, inv, "quant", {"oos_return": {"value": -0.02}, "fees": {"value": 0.002}, "trials": {"value": 24}},
377
+ summary="Backtested the thesis out of sample: it loses after costs.", q=q, x=x)
378
+ c = g.create(author="quant", type="claim", inv=inv, fields={"statement": "The thesis has no edge out of sample: -0.02 a year after fees.",
379
+ "scope": "Daily bars, 2022–2024 held out, after fees, 24 configurations tried."},
380
+ links=[{"rel": "answers", "to": q}, {"rel": "derived_from", "to": rs}])["id"]
381
+ g.review(author="risk", target=c, verdict="checked", method="Re-ran the OOS window.", notes="Negative after costs, as stated.")
382
+ assert _stage(g, q) == ("positioned", 7, False)
383
+ g.create(author=H, type="decision", inv=inv, fields={"choice": "No position: the thesis loses after costs out of sample.",
384
+ "reasons": "A negative out-of-sample return after costs is not a thesis to fund.", "kind": "no_position"},
385
+ links=[{"rel": "based_on", "to": c}])
386
+ assert g.stage(q)["complete"]
387
+ comp = {x_["name"]: x_ for x_ in g.completion(inv)["criteria"]}
388
+ assert comp["position_sized"]["met"] and "1 declined" in comp["position_sized"]["why"] and comp["nothing_past_review"]["met"]
389
+
390
+
391
+ def test_a_checked_inconclusive_answer_settles_a_question_without_a_source():
392
+ g, inv, q = _g("knowledge@2")
393
+ assert _stage(g, q) == ("sourced", 3, False)
394
+ # the search is a run; what it found (nothing) is a result; the honest answer derives from it
395
+ run = g.create(author="r", type="run", inv=inv, fields={"kind": "agent", "name": "search", "state": "succeeded"})["id"]
396
+ rs = g.create(author="r", type="result", inv=inv, fields={"summary": "Read 3 of 3 pages on the host; 0 mention decommissioning.",
397
+ "values": {"pages_read": {"k": 3, "n": 3}, "mentions": {"value": 0}}, "outcome": "negative", "kind": "observed"},
398
+ links=[{"rel": "produced", "from": run}])["id"]
399
+ c = g.create(author="r", type="claim", inv=inv, fields={"statement": "No page on the source host says when the dam was decommissioned.",
400
+ "scope": "The 3 of 3 pages on the source host, read in full.", "kind": "inconclusive"},
401
+ links=[{"rel": "answers", "to": q}, {"rel": "derived_from", "to": rs}])["id"]
402
+ assert _stage(g, q) == ("sourced", 3, False) # a claim alone settles nothing
403
+ g.review(author="critic", target=c, verdict="checked", method="Read the three pages.", notes="Nothing on decommissioning.")
404
+ x = g.stage(q)
405
+ assert x["complete"] and x["stage"] == "checked" and x["settled_by"] == "CL1" and "inconclusive" in x["settled_how"]
406
+
407
+
408
+ def test_accepting_an_order_is_not_the_questions_decision():
409
+ g, inv, q = _g("scientific@2")
410
+ o = g.issue_work(author=H, inv=inv, target=q, assignee="w", objective="State a hypothesis for Q1.", done_when=[{"p": "hypothesis_stated"}])
411
+ g.create(author="w", type="hypothesis", inv=inv, fields={"statement": "Caching halves the latency.", "predictions": ["p50 halves"]},
412
+ links=[{"rel": "answers", "to": q}])
413
+ g.return_work(author="w", order=o["order"], note="stated")
414
+ assert g.settle(author=H, inv=inv) # a decision that settles the order, based on H1
415
+ assert not _gate(g, q, "decided")["met"] # … is the harness accepting work, not a decision on Q1
416
+ assert "X1" not in g.report(inv)["markdown"] or "n" not in g.report(inv)["markdown"]
417
+
418
+
419
+ def test_registering_the_same_bytes_again_types_and_links_the_one_node():
420
+ g, inv, q = _g("software@2")
421
+ a = g.create(author=H, type="artifact", inv=inv, fields={"sha256": SHA_A, "path": "mod.py", "size": 20}) # the task's file
422
+ assert a["body"].get("kind") is None and not _gate(g, q, "diff_registered")["met"]
423
+ b = g.create(author="dev", type="artifact", inv=inv, fields={"sha256": SHA_A, "path": "mod.py", "kind": "diff",
424
+ "meta": {"base_sha": "0000000", "head_sha": "1111111", "files": ["mod.py"]}}, links=[{"rel": "part_of", "to": q}])
425
+ assert b["id"] == a["id"] and b["v"] == 2 and b["body"]["kind"] == "diff" and "typed as diff" in " ".join(b["notes"])
426
+ assert any(e["rel"] == "part_of" for e in b["out"])
427
+ assert _gate(g, q, "diff_registered")["met"] and _gate(g, q, "diff_registered")["evidence"] == ["A1"]
428
+ c = g.create(author="dev", type="artifact", inv=inv, fields={"sha256": SHA_A, "kind": "diff"}, links=[{"rel": "part_of", "to": q}])
429
+ assert c["v"] == 2 and len([e for e in c["out"] if e["rel"] == "part_of"]) == 1 # idempotent: nothing added twice
430
+ with pytest.raises(GraphError, match="ARTIFACT_MISSING_FIELDS"):
431
+ g.create(author="dev", type="artifact", inv=inv, fields={"sha256": SHA_B, "path": "x.py"})
432
+ g.create(author="dev", type="artifact", inv=inv, fields={"sha256": SHA_B, "path": "x.py", "kind": "diff"})
433
+
434
+
435
+ def test_a_verified_failure_settles_a_software_requirement():
436
+ g, inv, q = _g("software@2")
437
+ a1 = _diff(g, inv, q, "dev", SHA_A, "1111111")
438
+ run = g.create(author="tester", type="run", inv=inv, fields={"kind": "agent", "name": "checks", "state": "succeeded"},
439
+ links=[{"rel": "uses", "to": a1, "data": {"role": "code"}}])["id"]
440
+ rs = g.create(author="tester", type="result", inv=inv, fields={"summary": "Ran the checks: 1 of 2 pass.",
441
+ "values": {"passed": {"k": 1, "n": 2}}, "outcome": "negative"}, links=[{"rel": "produced", "from": run}])["id"]
442
+ lab = g.create(author="lab", type="run", inv=inv, fields={"kind": "lab", "name": "re-run", "state": "succeeded"},
443
+ links=[{"rel": "replicates", "to": run, "data": {"differs": "same_code"}}, {"rel": "uses", "to": a1, "data": {"role": "code"}}])["id"]
444
+ g.create(author="lab", type="result", inv=inv, fields={"summary": "Re-ran the checks: 1 of 2 pass.",
445
+ "values": {"passed": {"k": 1, "n": 2}}, "outcome": "negative"}, links=[{"rel": "produced", "from": lab}])
446
+ c = g.create(author="tester", type="claim", inv=inv, fields={"statement": "The change does not meet the requirement: 1 of 2 checks pass.",
447
+ "scope": "mod.py at head 1111111."}, links=[{"rel": "answers", "to": q}, {"rel": "derived_from", "to": rs}])["id"]
448
+ assert _stage(g, q) == ("verified", 3, False) # no passing run on the head, and there never will be
449
+ g.review(author="critic", target=c, verdict="checked", method="Read the lab run.", notes="One of two, as stated.")
450
+ x = g.stage(q)
451
+ assert x["complete"] and "verified failure" in x["settled_how"] and x["settled_by"] == "CL1"
452
+ assert not _gate(g, q, "tests_pass_current")["met"]
453
+ assert not next(c_ for c_ in g.completion(inv)["criteria"] if c_["name"] == "requirements_verified_current")["met"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes