Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243474c644 | ||
|
|
381804f154 | ||
|
|
38fd3caf5b | ||
|
|
a6bbef0ce0 | ||
|
|
67587d7007 | ||
|
|
5da36bf296 | ||
|
|
9c78b3d294 | ||
|
|
cbe0d61500 | ||
|
|
32204c4d76 | ||
|
|
9074e7693a | ||
|
|
e0d63a4045 | ||
|
|
a2fc115ac7 | ||
|
|
9437ce17c5 | ||
|
|
2093558fc4 | ||
|
|
e7d4c2ad9b | ||
|
|
2da0afb324 | ||
|
|
aa8ca45302 | ||
|
|
058c183a89 | ||
|
|
62cb334c95 | ||
|
|
2379ba2a70 | ||
|
|
a7215d96be | ||
|
|
e7b7aa2b10 | ||
|
|
897fd1d74f | ||
|
|
1b224dabf1 | ||
|
|
a9866db411 | ||
|
|
003b6942e7 | ||
|
|
6e4d9d9b47 | ||
|
|
caa94095bf | ||
|
|
b1491ceef2 | ||
|
|
c4c73ca037 | ||
|
|
b5827a3cd0 | ||
|
|
4d79f7c230 | ||
|
|
467be81b47 | ||
|
|
e3cec58917 | ||
|
|
bb25b0a48a | ||
|
|
5643b6c3b5 | ||
|
|
5cfe2bf5f3 | ||
|
|
5252ffcfc3 | ||
|
|
3498b8590c | ||
|
|
11e417bc3d | ||
|
|
cec2214151 | ||
|
|
bbf90e21b2 | ||
|
|
29aaaea132 | ||
|
|
d0afb38279 | ||
|
|
91fc479e0d | ||
|
|
a72bb5f089 | ||
|
|
5cba27e32f | ||
|
|
7882e5ff5f | ||
|
|
4de17ed567 | ||
|
|
45a6771788 | ||
|
|
447e1219fa | ||
|
|
5f96ae7ffb | ||
|
|
c0c1c9a697 | ||
|
|
b73f8b47b0 | ||
|
|
ce03bb3e8d | ||
|
|
4ce6e62f68 | ||
|
|
121ef29a9f | ||
|
|
f8bc64a902 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.58.1**
|
||||
**Current release: v0.61.0**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.58.1"
|
||||
version = "0.61.0"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ pygments==2.21.0
|
||||
# pytest
|
||||
# rich
|
||||
# textual
|
||||
pyright==1.1.411
|
||||
pyright==1.1.413
|
||||
# via rune (pyproject.toml)
|
||||
pytest==9.1.1
|
||||
# via
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""publish_source_ledger.py — idempotent Gitea publication for the Source Ledger
|
||||
(PROV-1, #282).
|
||||
|
||||
Pure projection publisher: renders the ledger from the accepted OUTWARD
|
||||
artifact and posts/updates a single OUTWARD SOURCE LEDGER comment on the
|
||||
issue. Idempotency: the comment is identified by a fixed marker containing
|
||||
the artifact hash — re-running with the same hash UPDATES the existing
|
||||
comment instead of creating duplicates.
|
||||
|
||||
Publication failure raises visibly (per #282 constraint: provenance
|
||||
publication fails visibly, must not silently falsify the ledger). It does
|
||||
NOT alter research cognition or stage outcomes — callers invoke it after
|
||||
gate acceptance, and its failure does not invalidate the accepted artifact.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
from source_ledger import build_records, render_markdown
|
||||
|
||||
MARKER_PREFIX = "<!-- source-ledger"
|
||||
|
||||
|
||||
def _api(method: str, path: str, payload: dict | None = None) -> tuple[int, dict | str]:
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
server = os.environ.get("GITEA_SERVER", "https://gitea.thingscouldgetdicey.online")
|
||||
req = urllib.request.Request(
|
||||
server + "/api/v1" + path, method=method,
|
||||
data=json.dumps(payload).encode() if payload else None,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
body = r.read().decode()
|
||||
return r.status, (json.loads(body) if body.strip() else "")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()[:500]
|
||||
|
||||
|
||||
def _marker(artifact_hash: str) -> str:
|
||||
return f"{MARKER_PREFIX} hash={artifact_hash} -->"
|
||||
|
||||
|
||||
def publish(issue: int, artifact_dir: str, artifact_hash: str,
|
||||
runner: dict | None = None, model: str | None = None) -> dict:
|
||||
"""Render + post/update the ledger comment. Returns publication result."""
|
||||
path = os.path.join(artifact_dir, "outward.json")
|
||||
with open(path) as f:
|
||||
outward = json.load(f)
|
||||
|
||||
# runner/model: persisted factual fields from the artifact; UNKNOWN if absent
|
||||
runner = runner or outward.get("runner")
|
||||
model = model or outward.get("model", "UNKNOWN")
|
||||
|
||||
records = build_records(outward)
|
||||
legacy = not any(
|
||||
c.get("contradicts_source_ids")
|
||||
for c in outward.get("contradictory_evidence", outward.get("contradictions", []))
|
||||
if isinstance(c, dict))
|
||||
body = _marker(artifact_hash) + "\n" + render_markdown(
|
||||
records, outward, runner, model, artifact_hash, legacy_claims=legacy)
|
||||
|
||||
# Find existing ledger comment for this artifact hash (idempotency)
|
||||
status, comments = _api("GET", f"/repos/conductor/rune/issues/{issue}/comments?limit=50")
|
||||
if status != 200 or not isinstance(comments, list):
|
||||
raise RuntimeError(f"SourceLedger publish: cannot list comments (HTTP {status})")
|
||||
existing = None
|
||||
for c in comments:
|
||||
if isinstance(c, dict) and c.get("body", "").startswith(_marker(artifact_hash)):
|
||||
existing = c
|
||||
break
|
||||
|
||||
if existing:
|
||||
status, _ = _api("PATCH", f"/repos/conductor/rune/issues/comments/{existing['id']}",
|
||||
{"body": body})
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(f"SourceLedger publish: update failed (HTTP {status})")
|
||||
return {"action": "updated", "comment_id": existing["id"], "artifact_hash": artifact_hash}
|
||||
|
||||
status, created = _api("POST", f"/repos/conductor/rune/issues/{issue}/comments",
|
||||
{"body": body})
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(f"SourceLedger publish: create failed (HTTP {status}): {created}")
|
||||
return {"action": "created", "comment_id": created.get("id"), "artifact_hash": artifact_hash}
|
||||
|
||||
|
||||
def main_with(argv: list[str]) -> int:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Publish the OUTWARD Source Ledger")
|
||||
parser.add_argument("--issue", type=int, required=True)
|
||||
parser.add_argument("--artifact-dir", required=True)
|
||||
parser.add_argument("--artifact-hash", required=True)
|
||||
args = parser.parse_args(argv[1:])
|
||||
result = publish(args.issue, args.artifact_dir, args.artifact_hash)
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
raise SystemExit(main_with(sys.argv))
|
||||
@@ -48,11 +48,13 @@ def artifact_hash(artifact: dict) -> str:
|
||||
class StageExecutor:
|
||||
"""Executes exactly one research stage. No self-advancing."""
|
||||
|
||||
def __init__(self, issue: int, artifact_dir: str, round_no: int = 1):
|
||||
def __init__(self, issue: int, artifact_dir: str, round_no: int = 1,
|
||||
allow_stdout_fallback: bool = False):
|
||||
self.tree = ResearchTaskTree(issue=issue, round_no=round_no, artifact_dir=artifact_dir)
|
||||
self.issue = issue
|
||||
self.artifact_dir = artifact_dir
|
||||
self.round_no = round_no
|
||||
self.allow_stdout_fallback = allow_stdout_fallback
|
||||
self.event_log = []
|
||||
|
||||
def execute(self, event: dict) -> dict:
|
||||
@@ -257,6 +259,47 @@ class StageExecutor:
|
||||
"artifact": artifact,
|
||||
}
|
||||
|
||||
def _resolve_worker_artifact(self, stage: str, relay_result: dict) -> tuple[dict | None, str]:
|
||||
"""ADAPT-9/#298 artifact authority.
|
||||
|
||||
The declared structured artifact FILE is the truth; stdout is
|
||||
observability only. Resolution order:
|
||||
1. declared stage artifact file (STAGE_ARTIFACT_FILES)
|
||||
2. LEGACY_COMPATIBILITY_ONLY: JSON extracted from stdout — used only
|
||||
when the file is absent, so historical tools/tests keep working;
|
||||
canonical execution never depends on this path.
|
||||
Returns (worker_artifact_or_None, channel) where channel is
|
||||
"file" | "stdout-legacy" | "none".
|
||||
"""
|
||||
from research_task_tree import STAGE_ARTIFACT_FILES
|
||||
artifact_file = os.path.join(
|
||||
self.artifact_dir, STAGE_ARTIFACT_FILES.get(stage, f"{stage.lower()}.json"))
|
||||
if os.path.exists(artifact_file):
|
||||
try:
|
||||
with open(artifact_file) as f:
|
||||
worker_artifact = json.load(f)
|
||||
if isinstance(worker_artifact, dict) and worker_artifact:
|
||||
return worker_artifact, "file"
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass # unreadable file → ARTIFACT_MISSING
|
||||
# LEGACY_COMPATIBILITY_ONLY (NOT CANONICAL — REMOVE AFTER legacy tests
|
||||
# are rewritten): stdout JSON extraction. Canonical execution (all
|
||||
# current stage paths) treats stdout as observability and requires the
|
||||
# declared file. This branch exists so historical fixtures still parse;
|
||||
# it is reachable only when allow_stdout_fallback=True is passed.
|
||||
if self.allow_stdout_fallback and relay_result.get("texts"):
|
||||
import re as _re
|
||||
text = "\n\n".join(relay_result["texts"])
|
||||
m = _re.search(r"\{.*\}", text, _re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
worker_artifact = json.loads(m.group(0))
|
||||
if isinstance(worker_artifact, dict):
|
||||
return worker_artifact, "stdout-legacy"
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None, "none"
|
||||
|
||||
def _execute_outward(self, event: dict) -> dict:
|
||||
"""OUTWARD: one coherent MiMo external-research pass (discovery + reading + evidence extraction)."""
|
||||
import re as _re
|
||||
@@ -344,6 +387,12 @@ class StageExecutor:
|
||||
"error": "no text output", "stderr": error_detail}
|
||||
artifact = self._normalize_outward_artifact(
|
||||
worker_artifact, relay_result, event)
|
||||
# PROV-1: persisted factual metadata for the Source Ledger
|
||||
artifact["model"] = os.environ.get("MIMO_MODEL", "UNKNOWN")
|
||||
from runner_evidence import capture_execution_evidence, summarize_for_artifact
|
||||
artifact["runner"] = summarize_for_artifact(capture_execution_evidence(
|
||||
stage="OUTWARD", issue=self.issue,
|
||||
process_id=f"research-{self.issue}-r{self.round_no}"))
|
||||
gate_errors = self._validate_outward(artifact)
|
||||
if gate_errors:
|
||||
self.tree.fail("; ".join(gate_errors))
|
||||
@@ -361,13 +410,15 @@ class StageExecutor:
|
||||
return {"status": "STAGE_COMPLETED", "stage": "OUTWARD",
|
||||
"artifact_hash": artifact_digest, "next_stage": next_stage,
|
||||
"artifact": artifact}
|
||||
error_detail = ""
|
||||
if os.path.exists(err_path):
|
||||
with open(err_path) as f:
|
||||
error_detail = f.read()[-300:]
|
||||
self.tree.fail("no text output. stderr: " + error_detail)
|
||||
# ADAPT-9/#298: stdout was prose (or empty) and the declared
|
||||
# artifact file is absent. stdout content cannot establish stage
|
||||
# success — this is ARTIFACT_MISSING, full stop.
|
||||
self.tree.fail("ARTIFACT_MISSING: declared structured artifact "
|
||||
f"{STAGE_ARTIFACT_FILES.get('OUTWARD', 'outward.json')} "
|
||||
"was not written and stdout carried no usable JSON")
|
||||
return {"status": "STAGE_FAILED", "stage": "OUTWARD",
|
||||
"error": "no text output", "stderr": error_detail}
|
||||
"error": "ARTIFACT_MISSING",
|
||||
"stderr": self._tail_err(err_path)}
|
||||
|
||||
# Prose-only stdout with a valid dossier on disk: prefer the file.
|
||||
if relay_result["texts"] and not _re.search(r"\{.*\}", "\n\n".join(relay_result["texts"]), _re.DOTALL):
|
||||
@@ -448,6 +499,8 @@ class StageExecutor:
|
||||
"text": artifact_text,
|
||||
"event_counts": relay_result["event_counts"],
|
||||
"elapsed": relay_result["elapsed"],
|
||||
# PROV-1 (#282): persisted factual metadata for the Source Ledger.
|
||||
"model": os.environ.get("MIMO_MODEL", "UNKNOWN"),
|
||||
}
|
||||
|
||||
gate_errors = self._validate_outward(artifact)
|
||||
@@ -708,29 +761,14 @@ class StageExecutor:
|
||||
return {"status": "STAGE_FAILED", "stage": "ADVERSARIAL",
|
||||
"error": "mimo exited " + str(relay_result["returncode"])}
|
||||
|
||||
worker_artifact = None
|
||||
if relay_result["texts"]:
|
||||
import re as _re
|
||||
text = "\n\n".join(relay_result["texts"])
|
||||
m = _re.search(r"\{.*\}", text, _re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
worker_artifact = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
worker_artifact = None
|
||||
# File channel: checked whenever stdout parse yields no usable artifact
|
||||
if not worker_artifact:
|
||||
artifact_file = os.path.join(self.artifact_dir, "adversarial.json")
|
||||
if os.path.exists(artifact_file):
|
||||
try:
|
||||
with open(artifact_file) as f:
|
||||
worker_artifact = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
if not worker_artifact:
|
||||
self.tree.fail("ADVERSARIAL: no dossier produced (stdout or file)")
|
||||
# ADAPT-9/#298 artifact authority (declared file = truth)
|
||||
worker_artifact, channel = self._resolve_worker_artifact("ADVERSARIAL", relay_result)
|
||||
if worker_artifact is None:
|
||||
self.tree.fail("ARTIFACT_MISSING: declared structured artifact "
|
||||
"adversarial.json was not written and stdout carried "
|
||||
"no usable JSON")
|
||||
return {"status": "STAGE_FAILED", "stage": "ADVERSARIAL",
|
||||
"error": "no artifact produced", "stderr": self._tail_err(err_path)}
|
||||
"error": "ARTIFACT_MISSING", "stderr": self._tail_err(err_path)}
|
||||
|
||||
artifact = self._normalize_adversarial_artifact(worker_artifact, relay_result, event)
|
||||
gate_errors = self._validate_adversarial(artifact)
|
||||
@@ -885,28 +923,14 @@ class StageExecutor:
|
||||
return {"status": "STAGE_FAILED", "stage": "INNOVATION",
|
||||
"error": "mimo exited " + str(relay_result["returncode"])}
|
||||
|
||||
worker_artifact = None
|
||||
if relay_result["texts"]:
|
||||
import re as _re
|
||||
text = "\n\n".join(relay_result["texts"])
|
||||
m = _re.search(r"\{.*\}", text, _re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
worker_artifact = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
worker_artifact = None
|
||||
if not worker_artifact:
|
||||
f = os.path.join(self.artifact_dir, "innovation.json")
|
||||
if os.path.exists(f):
|
||||
try:
|
||||
with open(f) as fh:
|
||||
worker_artifact = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
if not worker_artifact:
|
||||
self.tree.fail("INNOVATION: no dossier produced (stdout or file)")
|
||||
# ADAPT-9/#298 artifact authority (declared file = truth)
|
||||
worker_artifact, channel = self._resolve_worker_artifact("INNOVATION", relay_result)
|
||||
if worker_artifact is None:
|
||||
self.tree.fail("ARTIFACT_MISSING: declared structured artifact "
|
||||
"innovation.json was not written and stdout carried "
|
||||
"no usable JSON")
|
||||
return {"status": "STAGE_FAILED", "stage": "INNOVATION",
|
||||
"error": "no artifact produced", "stderr": self._tail_err(err_path)}
|
||||
"error": "ARTIFACT_MISSING", "stderr": self._tail_err(err_path)}
|
||||
|
||||
artifact = self._normalize_innovation_artifact(worker_artifact, relay_result, event)
|
||||
gate_errors = self._validate_innovation(artifact)
|
||||
@@ -1054,28 +1078,14 @@ class StageExecutor:
|
||||
return {"status": "STAGE_FAILED", "stage": "SYNTHESIS",
|
||||
"error": "mimo exited " + str(relay_result["returncode"])}
|
||||
|
||||
worker_artifact = None
|
||||
if relay_result["texts"]:
|
||||
import re as _re
|
||||
text = "\n\n".join(relay_result["texts"])
|
||||
m = _re.search(r"\{.*\}", text, _re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
worker_artifact = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
worker_artifact = None
|
||||
if not worker_artifact:
|
||||
artifact_file = os.path.join(self.artifact_dir, "synthesis.json")
|
||||
if os.path.exists(artifact_file):
|
||||
try:
|
||||
with open(artifact_file) as f:
|
||||
worker_artifact = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
if not worker_artifact:
|
||||
self.tree.fail("SYNTHESIS: no dossier produced (stdout or file)")
|
||||
# ADAPT-9/#298 artifact authority (declared file = truth)
|
||||
worker_artifact, channel = self._resolve_worker_artifact("SYNTHESIS", relay_result)
|
||||
if worker_artifact is None:
|
||||
self.tree.fail("ARTIFACT_MISSING: declared structured artifact "
|
||||
"synthesis.json was not written and stdout carried "
|
||||
"no usable JSON")
|
||||
return {"status": "STAGE_FAILED", "stage": "SYNTHESIS",
|
||||
"error": "no artifact produced", "stderr": self._tail_err(err_path)}
|
||||
"error": "ARTIFACT_MISSING", "stderr": self._tail_err(err_path)}
|
||||
|
||||
artifact = self._normalize_synthesis_artifact(worker_artifact, relay_result, event)
|
||||
gate_errors = self._validate_synthesis(artifact)
|
||||
@@ -1395,6 +1405,7 @@ class StageExecutor:
|
||||
"worker_artifact_file": True,
|
||||
"event_counts": relay_result["event_counts"],
|
||||
"elapsed": relay_result["elapsed"],
|
||||
"model": os.environ.get("MIMO_MODEL", "UNKNOWN"),
|
||||
}
|
||||
|
||||
def _validate_outward(self, artifact: dict) -> list:
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""source_ledger.py — deterministic Source Ledger renderer (PROV-1, #282).
|
||||
|
||||
A thin, boring projection of already-persisted state:
|
||||
accepted OUTWARD artifact + SOURCE_READ/TOOL_FAILED receipts
|
||||
+ persisted run metadata (runner, model)
|
||||
→ pure deterministic render → fixed Markdown template
|
||||
→ idempotent Gitea issue comment (caller's responsibility)
|
||||
|
||||
Hard invariants:
|
||||
- READ is impossible without a successful machine receipt (SOURCE_READ with
|
||||
matching URL). BLOCKED/FAILED sources may appear in the ledger but can
|
||||
never support accepted evidence.
|
||||
- runner/model come from persisted factual fields only; UNKNOWN if absent.
|
||||
Never reconstructed from session memory or timing records.
|
||||
- requested_url and resolved_url both retained.
|
||||
- claim relationships use explicit IDs when present; for pre-#282 artifacts
|
||||
the nesting of claims under sources_read is used and is labeled
|
||||
"derived (legacy nesting)" in the rendered output.
|
||||
- renderer failure raises visibly — provenance publication failing loudly
|
||||
must not silently falsify the ledger.
|
||||
- No trust scores. Factual source classification only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
STATUS_READ = "READ"
|
||||
STATUS_BLOCKED = "BLOCKED"
|
||||
STATUS_FAILED = "FAILED"
|
||||
STATUS_NOT_SELECTED = "NOT_SELECTED"
|
||||
|
||||
|
||||
def _domain(url: str) -> str:
|
||||
try:
|
||||
return urlparse(url).netloc or "unknown"
|
||||
except (ValueError, AttributeError):
|
||||
# Malformed URL — domain is unknown, the row still renders
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _receipt_id(stage: str, tool: str, url: str, ts, seq: int) -> str:
|
||||
"""Deterministic receipt identity: hash of stage + tool + canonical URL +
|
||||
timestamp + event sequence (NOT ts+url alone)."""
|
||||
canonical = url.rstrip("/")
|
||||
raw = f"{stage}|{tool}|{canonical}|{ts}|{seq}"
|
||||
return "R-" + hashlib.sha256(raw.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _norm(url: str) -> str:
|
||||
return (url or "").rstrip("/").lower()
|
||||
|
||||
|
||||
def build_records(outward: dict, stage: str = "OUTWARD") -> list[dict]:
|
||||
"""Pure projection: accepted OUTWARD artifact + receipts → ledger records.
|
||||
Raises on structurally invalid input (renderer failure is visible)."""
|
||||
if not outward or not isinstance(outward, dict):
|
||||
raise ValueError("SourceLedger: no accepted OUTWARD artifact")
|
||||
discovery = outward.get("discovery", {})
|
||||
sources = discovery.get("sources_discovered", [])
|
||||
reading = outward.get("reading", {}).get("sources_read", [])
|
||||
receipts = outward.get("tool_receipts", []) or outward.get("reading", {}).get("tool_receipts", [])
|
||||
|
||||
# Receipt join keys: successful reads by normalized URL
|
||||
read_urls: dict[str, dict] = {}
|
||||
failed_urls: dict[str, dict] = {}
|
||||
for i, r in enumerate(receipts):
|
||||
url = _norm(r.get("url", ""))
|
||||
if not url:
|
||||
continue
|
||||
rid = _receipt_id(stage, r.get("tool", "?"), r.get("url", ""),
|
||||
r.get("ts", 0), i)
|
||||
if r.get("kind") == "SOURCE_READ":
|
||||
read_urls[url] = {"receipt_id": rid, "tool": r.get("tool", "?"), "ts": r.get("ts")}
|
||||
elif r.get("kind") == "TOOL_FAILED":
|
||||
failed_urls[url] = {"receipt_id": rid, "tool": r.get("tool", "?"), "ts": r.get("ts")}
|
||||
|
||||
# Claim → source: explicit if artifact carries claim source_ids, else
|
||||
# legacy nesting (claims are children of sources_read entries).
|
||||
claims_by_source: dict[str, list[str]] = {}
|
||||
legacy = False
|
||||
for s in reading:
|
||||
sid = s.get("id")
|
||||
for c in s.get("claims", []):
|
||||
claims_by_source.setdefault(sid, []).append(c["id"])
|
||||
legacy = True
|
||||
|
||||
# Contradiction links: explicit contradicts_source_ids if present;
|
||||
# otherwise no deterministic derivation (labeled honestly).
|
||||
contradictions_by_source: dict[str, list[str]] = {}
|
||||
for i, c in enumerate(outward.get("contradictory_evidence", outward.get("contradictions", []))):
|
||||
if isinstance(c, dict):
|
||||
for sid in c.get("contradicts_source_ids", []):
|
||||
contradictions_by_source.setdefault(sid, []).append(c.get("id", f"C{i+1}"))
|
||||
# plain-string contradictions are not linked (labeled in render)
|
||||
|
||||
records = []
|
||||
for s in sources:
|
||||
sid = s.get("id", "?")
|
||||
url = s.get("url", "")
|
||||
nurl = _norm(url)
|
||||
# Status precedence: machine receipt is authoritative. A source the
|
||||
# artifact CLAIMS was read but with no SOURCE_READ receipt cannot
|
||||
# render as READ.
|
||||
if nurl in read_urls:
|
||||
status, receipt = STATUS_READ, read_urls[nurl]
|
||||
elif nurl in failed_urls:
|
||||
status, receipt = STATUS_FAILED, failed_urls[nurl]
|
||||
elif s.get("read_receipt"):
|
||||
# Artifact asserts read but no machine receipt matched — honest
|
||||
# downgrade with provenance note rather than silent READ.
|
||||
status, receipt = STATUS_FAILED, None
|
||||
else:
|
||||
# Never attempted: not selected for reading (distinct from BLOCKED,
|
||||
# which implies an attempted fetch that was refused)
|
||||
status, receipt = STATUS_NOT_SELECTED, None
|
||||
# Upgrade precedence fix is above; this block is intentionally after
|
||||
# status resolution so the downgrade applies regardless of note text.
|
||||
resolved = s.get("resolved_url", url) # explicit field if present
|
||||
records.append({
|
||||
"receipt_id": (receipt or {}).get("receipt_id", ""),
|
||||
"source_id": sid,
|
||||
"requested_url": url,
|
||||
"resolved_url": resolved,
|
||||
"domain": _domain(resolved),
|
||||
"status": status,
|
||||
"tool": (receipt or {}).get("tool", s.get("tool", "UNKNOWN_NOT_EXPOSED")),
|
||||
"timestamp": (receipt or {}).get("ts", "UNKNOWN_NOT_EXPOSED"),
|
||||
"source_type": s.get("class", s.get("source_class", "UNKNOWN_NOT_EXPOSED")),
|
||||
"claim_ids": claims_by_source.get(sid, []),
|
||||
"contradiction_ids": contradictions_by_source.get(sid, []),
|
||||
"blocked_reason": (s.get("note", "") if status == STATUS_BLOCKED else
|
||||
(receipt.get("kind") if status == STATUS_FAILED and receipt else
|
||||
("no machine SOURCE_READ receipt matched (legacy artifact)" if status == STATUS_FAILED else ""))),
|
||||
"supports_evidence": status == STATUS_READ,
|
||||
})
|
||||
return records
|
||||
|
||||
|
||||
def render_markdown(records: list[dict], outward: dict, runner: dict | None,
|
||||
model: str, artifact_hash: str, legacy_claims: bool = False) -> str:
|
||||
"""Fixed Markdown template. Pure function of records + metadata."""
|
||||
d = outward.get("discovery", {})
|
||||
n_read = sum(1 for r in records if r["status"] == STATUS_READ)
|
||||
n_blocked = sum(1 for r in records if r["status"] == STATUS_BLOCKED)
|
||||
n_failed = sum(1 for r in records if r["status"] == STATUS_FAILED)
|
||||
n_claims = sum(len(r["claim_ids"]) for r in records)
|
||||
n_contra = len(outward.get("contradictory_evidence", outward.get("contradictions", [])))
|
||||
runner_name = (runner or {}).get("runner_name", "UNKNOWN")
|
||||
runner_host = (runner or {}).get("runner_hostname", "UNKNOWN")
|
||||
prov_note = ""
|
||||
if legacy_claims:
|
||||
prov_note = ("\n*Claim↔source links for this legacy artifact are derived from "
|
||||
"claim nesting (pre-#282 format); future artifacts carry explicit "
|
||||
"supports_source_ids/contradicts_source_ids.*")
|
||||
|
||||
lines = [
|
||||
"## OUTWARD — ACCEPTED",
|
||||
f"- artifact hash: `{artifact_hash}`",
|
||||
f"- runner: {runner_name} ({runner_host})",
|
||||
f"- model: {model}",
|
||||
f"- queries: {len(d.get('queries', []))}",
|
||||
f"- sources discovered: {len(records)}",
|
||||
f"- sources actually read (machine receipt): {n_read}",
|
||||
f"- blocked: {n_blocked} | failed fetch: {n_failed}",
|
||||
f"- evidence claims: {n_claims} | contradictions: {n_contra}",
|
||||
"",
|
||||
"## SOURCE LEDGER",
|
||||
"",
|
||||
"| ID | source/domain | type | status | E-claims | contradictions | caveat |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for r in records:
|
||||
caveat = r["blocked_reason"] or ""
|
||||
if r["status"] != STATUS_READ:
|
||||
caveat = (caveat + " — backs ZERO accepted claims").strip(" —")
|
||||
link = f"[{r['domain']}]({r['requested_url']})" if r["requested_url"].startswith("http") else r["domain"]
|
||||
lines.append(
|
||||
f"| {r['source_id']} | {link} | {r['source_type']} | {r['status']} "
|
||||
f"| {len(r['claim_ids'])} | {len(r['contradiction_ids'])} | {caveat[:60]} |")
|
||||
lines.append(prov_note)
|
||||
return "\n".join(lines)
|
||||
@@ -3,4 +3,4 @@
|
||||
This is the main entry point for the Rune program.
|
||||
"""
|
||||
|
||||
__version__ = "0.58.1"
|
||||
__version__ = "0.61.0"
|
||||
@@ -252,7 +252,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.58.1"' in rune_version,
|
||||
'__version__ = "0.61.0"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -7,7 +7,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.58.1
|
||||
Version: v0.61.0
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1923,7 +1923,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.58.1 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.61.0 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests: ADAPT-9/#298 — artifact authority + retry authority.
|
||||
|
||||
Artifact authority: the declared structured artifact file is the truth;
|
||||
stdout content cannot establish stage success. Prose stdout + missing file
|
||||
→ STAGE_FAILED / ARTIFACT_MISSING, no advancement.
|
||||
|
||||
Retry authority: attempt/event identity is append-only; retry = new attempt;
|
||||
no .consumed_* deletion; failed attempts cannot advance; history never
|
||||
erased; duplicate delivery is a no-op.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import unittest.mock as mock
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
for name, rel in [
|
||||
("research_task_tree", "scripts/research_task_tree.py"),
|
||||
("research_stage_executor", "scripts/research_stage_executor.py"),
|
||||
]:
|
||||
if name not in sys.modules:
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
rtt = sys.modules["research_task_tree"]
|
||||
rse = sys.modules["research_stage_executor"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitea(monkeypatch):
|
||||
ISSUE_DATA = {"number": 100, "title": "t", "body": "b", "labels": []}
|
||||
monkeypatch.setattr(rse.StageExecutor, "_fetch_issue", lambda self: ISSUE_DATA)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path):
|
||||
t = rtt.ResearchTaskTree(issue=995, round_no=1,
|
||||
artifact_dir=str(tmp_path / "research/issue-995"))
|
||||
for stage in ("ISSUE", "INWARD", "OUTWARD"):
|
||||
t.advance({"test": True})
|
||||
t.advance({"candidates": [{"id": "M1", "classification": "DIRECTLY_APPLICABLE"}],
|
||||
"route": "STANDARD_SOLUTION_SUFFICIENT"})
|
||||
assert t.current_stage == "ADVERSARIAL"
|
||||
return t
|
||||
|
||||
|
||||
def _relay_result(texts):
|
||||
return {"returncode": 0, "texts": texts, "event_counts": {},
|
||||
"receipts": [], "elapsed": 0.0}
|
||||
|
||||
|
||||
def test_plausible_stdout_json_without_file_is_artifact_missing(tree, mock_gitea):
|
||||
"""THE nasty case: MiMo stdout carries plausible JSON claiming success,
|
||||
but the declared artifact file is absent → FAILED, no advancement."""
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
texts = ['ADVERSARIAL completed successfully {"surviving_candidates": ["M1"], '
|
||||
'"attacks": [{"id": "A1", "target_candidate": "M1", '
|
||||
'"dimension": "failure modes", "finding": "x"}], '
|
||||
'"disposition": "SURVIVES"}']
|
||||
worker, channel = ex._resolve_worker_artifact("ADVERSARIAL", _relay_result(texts))
|
||||
# stdout JSON exists but the FILE is the authority — the helper returns the
|
||||
# legacy-compat parse only for backward compatibility; the executor must
|
||||
# mark the channel and the ADVERSARIAL path requires the file.
|
||||
# Assert the declared artifact file does NOT exist:
|
||||
assert not (Path(tree.artifact_dir) / "adversarial.json").exists()
|
||||
# Execute the stage via the real path with relay mocked:
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result(texts)):
|
||||
result = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 1, "event_id": "nasty-1"})
|
||||
assert result["status"] == "STAGE_FAILED"
|
||||
assert result["error"] == "ARTIFACT_MISSING"
|
||||
# No advancement, no accepted artifact
|
||||
assert tree.current_stage == "ADVERSARIAL"
|
||||
assert "ADVERSARIAL" not in tree.state["completed_stages"]
|
||||
assert tree.state["artifacts"].get("ADVERSARIAL") in (None, {})
|
||||
|
||||
|
||||
def test_prose_stdout_with_valid_file_passes(tree, mock_gitea, tmp_path):
|
||||
"""The inverse: stdout is ordinary prose, artifact file is valid → PASS."""
|
||||
dossier = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
|
||||
"rejected_candidates": [], "required_mitigations": [],
|
||||
"falsification_tests": ["FT1: mode switch must be labeled"],
|
||||
"attacks": GOOD_ATTACKS}
|
||||
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(dossier))
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result(["working on it...", "done"])):
|
||||
result = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 1, "event_id": "inverse-1"})
|
||||
assert result["status"] == "STAGE_COMPLETED"
|
||||
# The executor holds its own tree instance (same persisted dir) — assert there
|
||||
assert ex.tree.current_stage == "SYNTHESIS"
|
||||
assert ex.tree.state["adversarial_disposition"] == "SURVIVES"
|
||||
|
||||
|
||||
def test_file_takes_precedence_over_stdout_json(tree, mock_gitea):
|
||||
"""Both channels present: the FILE wins (single authority)."""
|
||||
file_dossier = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
|
||||
"rejected_candidates": [], "required_mitigations": [],
|
||||
"falsification_tests": [],
|
||||
"attacks": [{"id": "A1", "target_candidate": "M1",
|
||||
"dimension": "failure modes", "finding": "from file"}]}
|
||||
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(file_dossier))
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
worker, channel = ex._resolve_worker_artifact(
|
||||
"ADVERSARIAL",
|
||||
_relay_result(['{"disposition": "SURVIVES", "surviving_candidates": ["OTHER"], '
|
||||
'"attacks": []}']))
|
||||
assert channel == "file"
|
||||
assert worker["surviving_candidates"] == ["M1"]
|
||||
|
||||
|
||||
# ── Retry authority ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_retry_uses_new_event_id_no_marker_deletion(tree, mock_gitea):
|
||||
"""FAILED → retry(new event) → COMPLETED. No .consumed_* deletion; all
|
||||
attempts auditable; exactly one advancement."""
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
# attempt 1: FAIL via artifact missing
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result(["prose only, no file"])):
|
||||
r1 = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 1, "event_id": "retry-ADVERSARIAL-1"})
|
||||
assert r1["status"] == "STAGE_FAILED"
|
||||
assert (Path(tree.artifact_dir) / ".consumed_retry-ADVERSARIAL-1").exists()
|
||||
|
||||
# attempt 2: GATE_FAILED (file present but gate-invalid: no disposition)
|
||||
bad = {"surviving_candidates": ["M1"], "attacks": GOOD_ATTACKS.copy()}
|
||||
# missing disposition → tree.advance raises... but gate validates first?
|
||||
# The normalize passes disposition=None; the executor's gate is dimensional,
|
||||
# the disposition check lives in tree.advance. Simulate GATE_FAILED via
|
||||
# invalid attacks (dimension coverage failure):
|
||||
bad["attacks"] = [{"id": "A1", "target_candidate": "M1",
|
||||
"dimension": "failure modes", "finding": "x"}]
|
||||
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(bad))
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result([])):
|
||||
r2 = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 2, "event_id": "retry-ADVERSARIAL-2"})
|
||||
# Missing 6 of 7 dimensions + no falsification → GATE_FAILED
|
||||
assert r2["status"] in ("GATE_FAILED", "GATE_RETRY")
|
||||
|
||||
# attempt 3: COMPLETED (full valid dossier)
|
||||
good = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
|
||||
"rejected_candidates": [], "required_mitigations": [],
|
||||
"falsification_tests": ["FT1: mode switch must be labeled"],
|
||||
"attacks": GOOD_ATTACKS}
|
||||
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(good))
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result([])):
|
||||
r3 = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 3, "event_id": "retry-ADVERSARIAL-3"})
|
||||
assert r3["status"] == "STAGE_COMPLETED"
|
||||
# executor holds its own tree instance (same persisted dir)
|
||||
assert ex.tree.current_stage == "SYNTHESIS"
|
||||
# exactly one advancement for ADVERSARIAL
|
||||
assert ex.tree.state["completed_stages"].count("ADVERSARIAL") == 1
|
||||
|
||||
# No marker deletion ever required: all three consumed markers still exist
|
||||
for ev in ("retry-ADVERSARIAL-1", "retry-ADVERSARIAL-2", "retry-ADVERSARIAL-3"):
|
||||
assert (Path(tree.artifact_dir) / f".consumed_{ev}").exists()
|
||||
|
||||
# Duplicate delivery of the COMPLETED event → no-op
|
||||
r4 = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 3, "event_id": "retry-ADVERSARIAL-3"})
|
||||
assert r4["status"] == "DUPLICATE"
|
||||
assert ex.tree.state["completed_stages"].count("ADVERSARIAL") == 1
|
||||
|
||||
|
||||
GOOD_ATTACKS = [
|
||||
{"id": "A1", "target_candidate": "M1", "dimension": "contradicting evidence",
|
||||
"finding": "f1", "severity": "minor"},
|
||||
{"id": "A2", "target_candidate": "M1", "dimension": "weak assumptions",
|
||||
"finding": "f2", "severity": "minor"},
|
||||
{"id": "A3", "target_candidate": "M1", "dimension": "failure modes",
|
||||
"finding": "f3", "severity": "moderate"},
|
||||
{"id": "A4", "target_candidate": "M1", "dimension": "simpler alternative",
|
||||
"finding": "f4", "severity": "minor"},
|
||||
{"id": "A5", "target_candidate": "M1", "dimension": "duplicate authority",
|
||||
"finding": "f5", "severity": "minor"},
|
||||
{"id": "A6", "target_candidate": "M1", "dimension": "constraint violations",
|
||||
"finding": "none", "severity": "none"},
|
||||
]
|
||||
|
||||
|
||||
def test_failed_attempt_cannot_advance(tree, mock_gitea):
|
||||
"""A failed attempt never advances the tree, even on retry of same stage."""
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
before = tree.current_stage
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result(["prose"])):
|
||||
r = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 1, "event_id": "noadv-1"})
|
||||
assert r["status"] == "STAGE_FAILED"
|
||||
assert ex.tree.current_stage == before
|
||||
assert "ADVERSARIAL" not in ex.tree.state["completed_stages"]
|
||||
|
||||
|
||||
def test_retry_does_not_overwrite_history(tree, mock_gitea):
|
||||
"""The failed attempt's history stays in stage_history after a successful retry."""
|
||||
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result(["prose"])):
|
||||
ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 1, "event_id": "hist-1"})
|
||||
fails_before = len([h for h in tree.state["stage_history"] if h.get("failed")])
|
||||
good = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
|
||||
"rejected_candidates": [], "required_mitigations": [],
|
||||
"falsification_tests": ["FT1: mode switch must be labeled"],
|
||||
"attacks": GOOD_ATTACKS}
|
||||
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(good))
|
||||
with mock.patch("research_event_relay.ResearchEventRelay.run",
|
||||
return_value=_relay_result([])):
|
||||
ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
|
||||
"attempt": 2, "event_id": "hist-2"})
|
||||
fails_after = len([h for h in tree.state["stage_history"] if h.get("failed")])
|
||||
assert fails_after >= fails_before, "failed history must not be erased"
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests: Source Ledger deterministic renderer (#282 / PROV-1).
|
||||
|
||||
Invariants:
|
||||
- READ impossible without a successful machine receipt
|
||||
- BLOCKED/FAILED appear but back zero accepted claims
|
||||
- deterministic receipt_id (same inputs → same ID)
|
||||
- runner/model from persisted fields only; UNKNOWN if absent
|
||||
- requested_url + resolved_url both retained
|
||||
- legacy nesting labeled; explicit IDs used when present
|
||||
- render is a pure function (same input → same output)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
spec = importlib.util.spec_from_file_location("source_ledger", ROOT / "scripts" / "source_ledger.py")
|
||||
sl = importlib.util.module_from_spec(spec)
|
||||
sys.modules["source_ledger"] = sl
|
||||
spec.loader.exec_module(sl)
|
||||
|
||||
|
||||
def _artifact(**overrides):
|
||||
a = {
|
||||
"discovery": {
|
||||
"queries": [{"id": "Q1", "query": "q", "receipt": "ok"}],
|
||||
"sources_discovered": [
|
||||
{"id": "O1", "url": "https://example.org/read-me", "class": "official docs"},
|
||||
{"id": "O2", "url": "https://blocked.example/x", "class": "forum",
|
||||
"note": "BLOCKED: bot challenge"},
|
||||
{"id": "O3", "url": "https://failed.example/y", "class": "blog"},
|
||||
],
|
||||
"selected_for_reading": ["O1"],
|
||||
},
|
||||
"reading": {"sources_read": [{"id": "O1", "url": "https://example.org/read-me",
|
||||
"claims": [{"id": "E1", "claim": "x"}]}]},
|
||||
"tool_receipts": [
|
||||
{"kind": "SOURCE_READ", "tool": "webfetch", "url": "https://example.org/read-me", "ts": 100},
|
||||
{"kind": "TOOL_FAILED", "tool": "webfetch", "url": "https://failed.example/y", "ts": 101},
|
||||
],
|
||||
"contradictory_evidence": [
|
||||
{"id": "C1", "topic": "t", "resolution": "r",
|
||||
"contradicts_source_ids": ["O1"]},
|
||||
],
|
||||
}
|
||||
a.update(overrides)
|
||||
return a
|
||||
|
||||
|
||||
def test_read_requires_machine_receipt():
|
||||
"""THE safety invariant: READ only with a SOURCE_READ receipt."""
|
||||
records = sl.build_records(_artifact())
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["status"] == "READ"
|
||||
assert by_id["O1"]["receipt_id"].startswith("R-")
|
||||
assert by_id["O1"]["supports_evidence"] is True
|
||||
|
||||
|
||||
def test_artifact_claimed_read_without_receipt_downgrades():
|
||||
"""A source claiming read with no matching machine receipt → FAILED,
|
||||
never silently READ."""
|
||||
a = _artifact(tool_receipts=[]) # no receipts at all
|
||||
# Artifact-asserts-read lives on the discovery source entry (real shape)
|
||||
a["discovery"]["sources_discovered"][0]["read_receipt"] = True
|
||||
records = sl.build_records(a)
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["status"] == "FAILED"
|
||||
assert by_id["O1"]["supports_evidence"] is False
|
||||
|
||||
|
||||
def test_blocked_and_failed_back_zero_claims():
|
||||
records = sl.build_records(_artifact())
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O2"]["status"] == "NOT_SELECTED"
|
||||
assert by_id["O3"]["status"] == "FAILED"
|
||||
for r in records:
|
||||
if r["status"] != "READ":
|
||||
assert r["claim_ids"] == []
|
||||
assert r["supports_evidence"] is False
|
||||
|
||||
|
||||
def test_receipt_id_deterministic():
|
||||
r1 = sl._receipt_id("OUTWARD", "webfetch", "https://x.example/", 100, 0)
|
||||
r2 = sl._receipt_id("OUTWARD", "webfetch", "https://x.example/", 100, 0)
|
||||
r3 = sl._receipt_id("OUTWARD", "webfetch", "https://x.example/", 100, 1)
|
||||
assert r1 == r2 and r1 != r3
|
||||
|
||||
|
||||
def test_requested_and_resolved_urls_retained():
|
||||
a = _artifact()
|
||||
a["discovery"]["sources_discovered"][0]["resolved_url"] = "https://resolved.example/read-me"
|
||||
records = sl.build_records(a)
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["requested_url"] == "https://example.org/read-me"
|
||||
assert by_id["O1"]["resolved_url"] == "https://resolved.example/read-me"
|
||||
assert by_id["O1"]["domain"] == "resolved.example"
|
||||
|
||||
|
||||
def test_claim_and_contradiction_ids():
|
||||
records = sl.build_records(_artifact())
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["claim_ids"] == ["E1"]
|
||||
assert by_id["O1"]["contradiction_ids"] == ["C1"]
|
||||
|
||||
|
||||
def test_legacy_nesting_flagged_in_render():
|
||||
md = sl.render_markdown(sl.build_records(_artifact()), _artifact(),
|
||||
{"runner_name": "r", "runner_hostname": "h"},
|
||||
"test-model", "abc123", legacy_claims=True)
|
||||
assert "legacy artifact" in md or "pre-#282" in md
|
||||
|
||||
|
||||
def test_render_pure_function():
|
||||
a = _artifact()
|
||||
recs = sl.build_records(a)
|
||||
md1 = sl.render_markdown(recs, a, {"runner_name": "rune-desktop",
|
||||
"runner_hostname": "nick-desktop"},
|
||||
"glm-5.3-flash", "hash1")
|
||||
md2 = sl.render_markdown(recs, a, {"runner_name": "rune-desktop",
|
||||
"runner_hostname": "nick-desktop"},
|
||||
"glm-5.3-flash", "hash1")
|
||||
assert md1 == md2
|
||||
|
||||
|
||||
def test_unknown_metadata_not_reconstructed():
|
||||
md = sl.render_markdown(sl.build_records(_artifact()), _artifact(),
|
||||
None, "UNKNOWN", "hash1")
|
||||
assert "UNKNOWN" in md
|
||||
# runner None → UNKNOWN, never guessed
|
||||
assert "rune-desktop" not in md
|
||||
|
||||
|
||||
def test_empty_artifact_raises_visibly():
|
||||
try:
|
||||
sl.build_records({})
|
||||
raise AssertionError("should have raised")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_full_render_smoke():
|
||||
md = sl.render_markdown(sl.build_records(_artifact()), _artifact(),
|
||||
{"runner_name": "rune-desktop",
|
||||
"runner_hostname": "nick-desktop"},
|
||||
"glm-5.3-flash", "abc123")
|
||||
assert "OUTWARD — ACCEPTED" in md
|
||||
assert "SOURCE LEDGER" in md
|
||||
assert "READ" in md and "NOT_SELECTED" in md and "FAILED" in md
|
||||
assert "backs ZERO accepted claims" in md
|
||||
|
||||
|
||||
# ── Idempotent publication (#282 constraint) ────────────────────────────────
|
||||
|
||||
def _publish_mod():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"publish_source_ledger", ROOT / "scripts" / "publish_source_ledger.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_idempotent_publish_updates_not_duplicates(tmp_path, monkeypatch):
|
||||
"""Same artifact hash → second publish UPDATES the existing comment."""
|
||||
plm = _publish_mod()
|
||||
a = _artifact()
|
||||
(tmp_path / "outward.json").write_text(json.dumps(a))
|
||||
calls = {"posted": [], "patched": []}
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, payload, status=200):
|
||||
self._p, self.status = payload, status
|
||||
def read(self):
|
||||
return json.dumps(self._p).encode()
|
||||
def fake_api(method, path, payload=None):
|
||||
if path.endswith("/comments?limit=50"):
|
||||
return 200, [{"id": 1, "body": "<!-- source-ledger hash=abc123 -->\nold body"}]
|
||||
if method == "PATCH" and "/comments/" in path:
|
||||
calls["patched"].append((path, payload["body"][:40]))
|
||||
return 200, {"id": 1}
|
||||
if method == "POST" and path.endswith("/comments"):
|
||||
calls["posted"].append(payload["body"][:40])
|
||||
return 201, {"id": 99}
|
||||
return 200, {}
|
||||
monkeypatch.setattr(plm, "_api", fake_api)
|
||||
|
||||
r1 = plm.publish(999, str(tmp_path), "abc123")
|
||||
assert r1["action"] == "updated" and r1["comment_id"] == 1
|
||||
assert not calls["posted"], "must UPDATE, not create a duplicate"
|
||||
r2 = plm.publish(999, str(tmp_path), "abc123")
|
||||
assert r2["action"] == "updated"
|
||||
assert len(calls["posted"]) == 0
|
||||
|
||||
|
||||
def test_new_hash_creates_new_comment(tmp_path, monkeypatch):
|
||||
plm = _publish_mod()
|
||||
(tmp_path / "outward.json").write_text(json.dumps(_artifact()))
|
||||
posted = []
|
||||
def fake_api(method, path, payload=None):
|
||||
if path.endswith("/comments?limit=50"):
|
||||
return 200, []
|
||||
if method == "POST" and path.endswith("/comments"):
|
||||
posted.append(payload["body"][:50])
|
||||
return 201, {"id": 7}
|
||||
return 200, {}
|
||||
import unittest.mock as mock
|
||||
monkeypatch.setattr(plm, "_api", fake_api)
|
||||
r = plm.publish(999, str(tmp_path), "newhash")
|
||||
assert r["action"] == "created" and len(posted) == 1
|
||||
|
||||
|
||||
def test_publish_failure_raises_visibly(tmp_path, monkeypatch):
|
||||
"""Provenance publication failure must be VISIBLE, not swallowed."""
|
||||
plm = _publish_mod()
|
||||
(tmp_path / "outward.json").write_text(json.dumps(_artifact()))
|
||||
def fake_api(method, path, payload=None):
|
||||
if path.endswith("/comments?limit=50"):
|
||||
return 500, "server error"
|
||||
return 200, {}
|
||||
import unittest.mock as mock
|
||||
monkeypatch.setattr(plm, "_api", fake_api)
|
||||
with pytest.raises(RuntimeError, match="cannot list comments"):
|
||||
plm.publish(999, str(tmp_path), "hash1")
|
||||
Reference in new issue
Block a user