rapier-runtime 0.1.0__py3-none-any.whl
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.
- rapier/__init__.py +70 -0
- rapier/_json.py +39 -0
- rapier/cli.py +244 -0
- rapier/convergence.py +93 -0
- rapier/envelope.py +66 -0
- rapier/ledger.py +71 -0
- rapier/manifest.py +75 -0
- rapier/mcp/__init__.py +9 -0
- rapier/mcp/server.py +184 -0
- rapier/mcp/tools.py +125 -0
- rapier/models.py +352 -0
- rapier/onboarding.py +128 -0
- rapier/pipeline.py +145 -0
- rapier/presets.py +83 -0
- rapier/secrets.py +72 -0
- rapier/stage.py +98 -0
- rapier/stages/__init__.py +6 -0
- rapier/stages/echo.py +28 -0
- rapier/stages/proposer/__init__.py +7 -0
- rapier/stages/proposer/phase_stages.py +101 -0
- rapier/stages/proposer/phases.py +201 -0
- rapier/stages/resolver/__init__.py +22 -0
- rapier/stages/resolver/_binding.py +33 -0
- rapier/stages/resolver/_extract.py +82 -0
- rapier/stages/resolver/anchored_fix.py +52 -0
- rapier/stages/resolver/author.py +62 -0
- rapier/stages/resolver/citation_gate.py +123 -0
- rapier/stages/resolver/compose.py +457 -0
- rapier/stages/resolver/cross_review.py +45 -0
- rapier/stages/resolver/definitiveness_gate.py +55 -0
- rapier/verify/__init__.py +4 -0
- rapier/verify/_bootstrap.py +66 -0
- rapier/verify/_llm_shim.py +131 -0
- rapier/verify/_vendor/PROVENANCE.md +14 -0
- rapier/verify/_vendor/cite_check.py +1093 -0
- rapier/verify/_vendor/lib_llm.py +288 -0
- rapier/verify/_vendor/spar_cross_review.py +296 -0
- rapier/verify/_vendor/spar_definitiveness_gate.py +770 -0
- rapier/verify/_vendor/spar_verify_gate.py +233 -0
- rapier/verify/_vendor/verify_grounding.py +795 -0
- rapier/verify/service.py +46 -0
- rapier_runtime-0.1.0.dist-info/METADATA +249 -0
- rapier_runtime-0.1.0.dist-info/RECORD +46 -0
- rapier_runtime-0.1.0.dist-info/WHEEL +4 -0
- rapier_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- rapier_runtime-0.1.0.dist-info/licenses/LICENSE +202 -0
rapier/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Rapier Runtime — a code-orchestrated engine that runs the SPARRING method.
|
|
2
|
+
|
|
3
|
+
Rapier executes a SPARRING *method* declared in a YAML manifest: grounded,
|
|
4
|
+
cross-vendor adversarial review for AI-in-the-loop decisions. This is M0 — the
|
|
5
|
+
skeleton (Envelope, Stage contract, Pipeline controller, model/provider layer,
|
|
6
|
+
ledger) proven with a dummy echo stage. The Resolver port (M1) and the Proposer
|
|
7
|
+
build (M2) come next.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .envelope import Artifact, Envelope, TraceEntry
|
|
12
|
+
from .manifest import Manifest
|
|
13
|
+
from .models import (
|
|
14
|
+
ModelClient,
|
|
15
|
+
ModelResponse,
|
|
16
|
+
ModelSpec,
|
|
17
|
+
OpenAICompatibleModelClient,
|
|
18
|
+
Policy,
|
|
19
|
+
PolicyError,
|
|
20
|
+
available_vendors,
|
|
21
|
+
build_client,
|
|
22
|
+
)
|
|
23
|
+
from .pipeline import Pipeline, StageSpec
|
|
24
|
+
from .stage import (
|
|
25
|
+
ConvergenceStage,
|
|
26
|
+
Stage,
|
|
27
|
+
StageContext,
|
|
28
|
+
TransformStage,
|
|
29
|
+
get_stage,
|
|
30
|
+
register_stage,
|
|
31
|
+
registered_stages,
|
|
32
|
+
)
|
|
33
|
+
from . import stages # noqa: F401 (registers built-in stages on import)
|
|
34
|
+
|
|
35
|
+
# Single source of truth is pyproject's version; read it from the installed
|
|
36
|
+
# package metadata so __version__ can never drift from what was published.
|
|
37
|
+
try:
|
|
38
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
__version__ = _pkg_version("rapier-runtime")
|
|
42
|
+
except PackageNotFoundError: # running from a source tree, not installed
|
|
43
|
+
__version__ = "0.0.0+source"
|
|
44
|
+
except Exception: # pragma: no cover - defensive
|
|
45
|
+
__version__ = "0.0.0+source"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Envelope",
|
|
49
|
+
"Artifact",
|
|
50
|
+
"TraceEntry",
|
|
51
|
+
"Stage",
|
|
52
|
+
"TransformStage",
|
|
53
|
+
"ConvergenceStage",
|
|
54
|
+
"StageContext",
|
|
55
|
+
"register_stage",
|
|
56
|
+
"get_stage",
|
|
57
|
+
"registered_stages",
|
|
58
|
+
"Manifest",
|
|
59
|
+
"Pipeline",
|
|
60
|
+
"StageSpec",
|
|
61
|
+
"ModelSpec",
|
|
62
|
+
"ModelClient",
|
|
63
|
+
"ModelResponse",
|
|
64
|
+
"OpenAICompatibleModelClient",
|
|
65
|
+
"Policy",
|
|
66
|
+
"PolicyError",
|
|
67
|
+
"build_client",
|
|
68
|
+
"available_vendors",
|
|
69
|
+
"__version__",
|
|
70
|
+
]
|
rapier/_json.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tolerant JSON extraction from model output (fences, surrounding prose)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_json_lenient(text: str | None) -> Any:
|
|
10
|
+
"""Best-effort: strip code fences, else grab the first balanced {...}/[...].
|
|
11
|
+
|
|
12
|
+
Returns {} on failure rather than raising — a malformed model reply becomes
|
|
13
|
+
an empty round, not a crash (fail-soft).
|
|
14
|
+
"""
|
|
15
|
+
if not text:
|
|
16
|
+
return {}
|
|
17
|
+
t = text.strip()
|
|
18
|
+
t = re.sub(r"^```(?:json)?\s*", "", t)
|
|
19
|
+
t = re.sub(r"\s*```$", "", t)
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(t)
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
for opener, closer in (("{", "}"), ("[", "]")):
|
|
25
|
+
start = t.find(opener)
|
|
26
|
+
if start == -1:
|
|
27
|
+
continue
|
|
28
|
+
depth = 0
|
|
29
|
+
for i in range(start, len(t)):
|
|
30
|
+
if t[i] == opener:
|
|
31
|
+
depth += 1
|
|
32
|
+
elif t[i] == closer:
|
|
33
|
+
depth -= 1
|
|
34
|
+
if depth == 0:
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(t[start : i + 1])
|
|
37
|
+
except Exception:
|
|
38
|
+
break
|
|
39
|
+
return {}
|
rapier/cli.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""The ``rapier`` CLI.
|
|
2
|
+
|
|
3
|
+
rapier sparring --request "should we do X?" # full ceremony
|
|
4
|
+
rapier spar --request "should we do X?" # Resolver only
|
|
5
|
+
rapier spar --request-file pack.md # read the pack from a file
|
|
6
|
+
rapier proposer --request "should we do X?" # Proposer only
|
|
7
|
+
rapier run --manifest path.yaml --request-file pack.md # a custom manifest
|
|
8
|
+
rapier doctor # which vendor keys are set
|
|
9
|
+
rapier init # scaffold a .env.example
|
|
10
|
+
rapier mcp # run the MCP server (stdio)
|
|
11
|
+
|
|
12
|
+
``spar`` / ``sparring`` are the thin adapters the SPARRING skills call.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import itertools
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from . import __version__, stages # noqa: F401 (ensure built-in stages are registered)
|
|
26
|
+
from .manifest import Manifest
|
|
27
|
+
from .presets import VERIFY_MODES, load_preset
|
|
28
|
+
|
|
29
|
+
# A ceremony is a sequence of model calls that each take tens of seconds — long
|
|
30
|
+
# enough that a novice fears it hung. Give each stage a plain-language label and,
|
|
31
|
+
# on a TTY, a live spinner + elapsed clock + "N/M" so it plainly reads as working.
|
|
32
|
+
_STAGE_LABELS = {
|
|
33
|
+
"author": "Drafting the recommendation",
|
|
34
|
+
"cross_review": "Independent cross-vendor challenge",
|
|
35
|
+
"anchored_fix": "Revising to address the challenge",
|
|
36
|
+
"definitiveness_gate": "Correctness check",
|
|
37
|
+
"citation_gate": "Grounding citations against public registries",
|
|
38
|
+
"compose": "Composing the report",
|
|
39
|
+
"spark": "Widening the options (SPARK)",
|
|
40
|
+
"pattern_lock": "Filtering repetition (Pattern Lock)",
|
|
41
|
+
"cut": "Committing one option (the Cut)",
|
|
42
|
+
"echo": "Echo",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Progress:
|
|
47
|
+
"""Render stage progress. On a TTY: an in-place spinner + elapsed seconds +
|
|
48
|
+
N/M counter, animated on a background thread while the (blocking) stage runs.
|
|
49
|
+
Piped/redirected: plain one-line-per-stage output, no control characters, so
|
|
50
|
+
logs stay clean."""
|
|
51
|
+
|
|
52
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
53
|
+
|
|
54
|
+
def __init__(self, total: int, stream=None):
|
|
55
|
+
self.total = total
|
|
56
|
+
self.stream = stream or sys.stderr
|
|
57
|
+
self.tty = bool(getattr(self.stream, "isatty", lambda: False)())
|
|
58
|
+
self.i = 0
|
|
59
|
+
self._label = ""
|
|
60
|
+
self._start = 0.0
|
|
61
|
+
self._stop = None
|
|
62
|
+
self._thread = None
|
|
63
|
+
|
|
64
|
+
def log(self, msg: str) -> None:
|
|
65
|
+
m = re.match(r"stage: (\w+)", msg)
|
|
66
|
+
if m:
|
|
67
|
+
self._finish()
|
|
68
|
+
self.i += 1
|
|
69
|
+
self._label = _STAGE_LABELS.get(m.group(1), m.group(1).replace("_", " "))
|
|
70
|
+
self._begin()
|
|
71
|
+
elif any(w in msg.lower() for w in ("fail", "cancel", "error")):
|
|
72
|
+
if self.tty:
|
|
73
|
+
self.stream.write("\r" + " " * 72 + "\r")
|
|
74
|
+
print(f"· {msg.strip()}", file=self.stream, flush=True)
|
|
75
|
+
elif not self.tty:
|
|
76
|
+
print(f"· {msg.strip()}", file=self.stream, flush=True)
|
|
77
|
+
|
|
78
|
+
def _begin(self) -> None:
|
|
79
|
+
if not self.tty:
|
|
80
|
+
print(f"· [{self.i}/{self.total}] {self._label}…", file=self.stream, flush=True)
|
|
81
|
+
return
|
|
82
|
+
self._start = time.monotonic()
|
|
83
|
+
self._stop = threading.Event()
|
|
84
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
85
|
+
self._thread.start()
|
|
86
|
+
|
|
87
|
+
def _spin(self) -> None:
|
|
88
|
+
frames = itertools.cycle(self._FRAMES)
|
|
89
|
+
while not self._stop.is_set():
|
|
90
|
+
el = int(time.monotonic() - self._start)
|
|
91
|
+
self.stream.write(f"\r {next(frames)} [{self.i}/{self.total}] {self._label}… {el}s ")
|
|
92
|
+
self.stream.flush()
|
|
93
|
+
self._stop.wait(0.1)
|
|
94
|
+
|
|
95
|
+
def _finish(self) -> None:
|
|
96
|
+
if not self._thread:
|
|
97
|
+
return
|
|
98
|
+
self._stop.set()
|
|
99
|
+
self._thread.join(timeout=1.0)
|
|
100
|
+
el = int(time.monotonic() - self._start)
|
|
101
|
+
self.stream.write(f"\r ✓ [{self.i}/{self.total}] {self._label} ({el}s){' ' * 12}\n")
|
|
102
|
+
self.stream.flush()
|
|
103
|
+
self._thread = None
|
|
104
|
+
|
|
105
|
+
def done(self) -> None:
|
|
106
|
+
self._finish()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _run(manifest: Manifest, request: str, ledger_dir: str | None, report_all: bool = False) -> int:
|
|
110
|
+
# Always capture a run: without an explicit --ledger-dir, write the full
|
|
111
|
+
# transcript + per-stage records under a temp dir and tell the user where.
|
|
112
|
+
default_root = ledger_dir is None
|
|
113
|
+
root = ledger_dir or os.path.join(tempfile.gettempdir(), "rapier-runs")
|
|
114
|
+
pipe = manifest.build()
|
|
115
|
+
progress = _Progress(total=len(pipe.stages))
|
|
116
|
+
try:
|
|
117
|
+
env = pipe.run(request, ledger_root=root, log=progress.log)
|
|
118
|
+
finally:
|
|
119
|
+
progress.done()
|
|
120
|
+
# Prefer the composed report if the pipeline produced one.
|
|
121
|
+
out = env.meta.get("report_md") or env.recommendation or ""
|
|
122
|
+
# --report-all: prepend the first half's handoff report (SPARRING is two
|
|
123
|
+
# parts — the Proposer commits an option, the Resolver pressure-tests it).
|
|
124
|
+
proposer_md = env.meta.get("proposer_report_md")
|
|
125
|
+
if report_all and proposer_md:
|
|
126
|
+
out = f"{proposer_md}\n\n{out}"
|
|
127
|
+
print(out)
|
|
128
|
+
run_id = env.meta.get("run_id")
|
|
129
|
+
if run_id:
|
|
130
|
+
run_dir = os.path.join(root, run_id)
|
|
131
|
+
hint = "Full transcript, per-stage records, and this report saved to:"
|
|
132
|
+
if default_root:
|
|
133
|
+
hint = "No --ledger-dir given, so the full transcript + records were saved to:"
|
|
134
|
+
print(f"\n· {hint}\n {run_dir}", file=sys.stderr)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_request(args) -> str:
|
|
139
|
+
"""The decision text — inline via --request, or read from --request-file
|
|
140
|
+
(``-`` for stdin). The mutually-exclusive group guarantees exactly one."""
|
|
141
|
+
path = getattr(args, "request_file", None)
|
|
142
|
+
if path:
|
|
143
|
+
if path == "-":
|
|
144
|
+
return sys.stdin.read()
|
|
145
|
+
with open(path, encoding="utf-8") as fh:
|
|
146
|
+
return fh.read()
|
|
147
|
+
return args.request
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main(argv: list[str] | None = None) -> int:
|
|
151
|
+
parser = argparse.ArgumentParser(
|
|
152
|
+
prog="rapier",
|
|
153
|
+
description="Rapier Runtime — run a SPARRING method from a manifest or preset.",
|
|
154
|
+
epilog="a ResourceForge project · docs: https://rapierruntime.com · "
|
|
155
|
+
"update: pip install -U rapier-runtime",
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--version", action="version", version=f"rapier-runtime {__version__}",
|
|
159
|
+
help="print the installed version and exit",
|
|
160
|
+
)
|
|
161
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
162
|
+
|
|
163
|
+
def add_common(p):
|
|
164
|
+
src = p.add_mutually_exclusive_group(required=True)
|
|
165
|
+
src.add_argument("--request", help="the decision/request text, given inline")
|
|
166
|
+
src.add_argument(
|
|
167
|
+
"--request-file", metavar="PATH",
|
|
168
|
+
help="read the decision/request text from a file (use '-' for stdin) — "
|
|
169
|
+
"friendlier than --request for a multi-line context pack",
|
|
170
|
+
)
|
|
171
|
+
p.add_argument("--ledger-dir", default=None, help="write run artifacts (transcript, report, records) here")
|
|
172
|
+
|
|
173
|
+
for preset in ("spar", "sparring", "proposer"):
|
|
174
|
+
p = sub.add_parser(preset, help=f"run the '{preset}' ceremony preset")
|
|
175
|
+
add_common(p)
|
|
176
|
+
if preset in ("spar", "sparring"): # resolver knobs — no-ops for proposer-only
|
|
177
|
+
p.add_argument(
|
|
178
|
+
"--settle", type=int, default=0, metavar="N",
|
|
179
|
+
help="extra review-and-revise rounds on the recommendation for decision-stability (default 0)",
|
|
180
|
+
)
|
|
181
|
+
p.add_argument(
|
|
182
|
+
"--verify", choices=list(VERIFY_MODES), default="gate",
|
|
183
|
+
help="external-canon citation gate: off | gate (default) | round",
|
|
184
|
+
)
|
|
185
|
+
if preset == "sparring": # the Proposer half only exists in the full ceremony
|
|
186
|
+
p.add_argument(
|
|
187
|
+
"--report-all", action="store_true",
|
|
188
|
+
help="also surface the Proposer report (the committed option + its standing objections) above the Resolver report",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
run = sub.add_parser("run", help="run a custom manifest")
|
|
192
|
+
run.add_argument("--manifest", required=True, help="path to a pipeline manifest (YAML)")
|
|
193
|
+
add_common(run)
|
|
194
|
+
|
|
195
|
+
sub.add_parser("doctor", help="check which AI vendor keys are configured")
|
|
196
|
+
ip = sub.add_parser("init", help="scaffold a .env.example for vendor keys")
|
|
197
|
+
ip.add_argument("--dir", default=".", help="directory to write .env.example into (default: cwd)")
|
|
198
|
+
sub.add_parser("mcp", help="run the MCP server (stdio) exposing spar/sparring as tools")
|
|
199
|
+
|
|
200
|
+
args = parser.parse_args(argv)
|
|
201
|
+
|
|
202
|
+
if args.cmd == "doctor":
|
|
203
|
+
from .onboarding import doctor_report
|
|
204
|
+
|
|
205
|
+
print(doctor_report())
|
|
206
|
+
return 0
|
|
207
|
+
if args.cmd == "init":
|
|
208
|
+
from .onboarding import init as _init
|
|
209
|
+
|
|
210
|
+
_path, _created, instructions = _init(args.dir)
|
|
211
|
+
print(instructions)
|
|
212
|
+
return 0
|
|
213
|
+
if args.cmd == "mcp":
|
|
214
|
+
from .mcp import serve
|
|
215
|
+
|
|
216
|
+
return serve()
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
request = _resolve_request(args)
|
|
220
|
+
except OSError as e:
|
|
221
|
+
print(f"rapier: cannot read --request-file: {e}", file=sys.stderr)
|
|
222
|
+
return 2
|
|
223
|
+
if not (request or "").strip():
|
|
224
|
+
print("rapier: the request is empty", file=sys.stderr)
|
|
225
|
+
return 2
|
|
226
|
+
|
|
227
|
+
if args.cmd in ("spar", "sparring", "proposer"):
|
|
228
|
+
from .onboarding import preflight_error
|
|
229
|
+
|
|
230
|
+
err = preflight_error()
|
|
231
|
+
if err: # no vendor keys — fail loudly, not silently
|
|
232
|
+
print(err, file=sys.stderr)
|
|
233
|
+
return 2
|
|
234
|
+
preset = load_preset(
|
|
235
|
+
args.cmd, settle=getattr(args, "settle", 0), verify=getattr(args, "verify", "gate")
|
|
236
|
+
)
|
|
237
|
+
return _run(preset, request, args.ledger_dir, report_all=getattr(args, "report_all", False))
|
|
238
|
+
if args.cmd == "run":
|
|
239
|
+
return _run(Manifest.load(args.manifest), request, args.ledger_dir)
|
|
240
|
+
return 1 # pragma: no cover
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__": # pragma: no cover
|
|
244
|
+
raise SystemExit(main())
|
rapier/convergence.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The convergence primitive — the two-agent Generator×Challenger loop, once.
|
|
2
|
+
|
|
3
|
+
SPARK, Pattern Lock, and the Cut are all this loop with a different Challenger
|
|
4
|
+
function (expand / filter / close) and exit goal. Written once here; the phase
|
|
5
|
+
stages configure it.
|
|
6
|
+
|
|
7
|
+
Each round: the Generator produces/extends a payload; the Challenger applies its
|
|
8
|
+
function and raises artifact-cited concerns; both emit ``agree``. The phase
|
|
9
|
+
converges only when **both** agree (the proposer can't close alone). At the cap
|
|
10
|
+
it exits unresolved.
|
|
11
|
+
|
|
12
|
+
G3 (convergence integrity) is built in, not bolted on:
|
|
13
|
+
- *Instrumentation* — every round is recorded; ``no_op`` flags a phase whose
|
|
14
|
+
final payload never moved off the round-1 proposal (a rubber-stamp).
|
|
15
|
+
- *A reopen-capable integrity check* — an optional cross-vendor predicate that,
|
|
16
|
+
on a both-agree, can judge the convergence premature and reopen the phase
|
|
17
|
+
(up to ``reopen_cap``), so convergence is verified, not merely self-reported.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ConvergenceRound:
|
|
27
|
+
index: int
|
|
28
|
+
generator: dict[str, Any]
|
|
29
|
+
challenger: dict[str, Any]
|
|
30
|
+
both_agree: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ConvergenceResult:
|
|
35
|
+
converged: bool
|
|
36
|
+
payload: Any
|
|
37
|
+
rounds: list[ConvergenceRound] = field(default_factory=list)
|
|
38
|
+
resolved_at: int | None = None
|
|
39
|
+
no_op: bool = False
|
|
40
|
+
integrity_reopened: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# generator(prev_payload, challenger_concerns) -> {"payload", "agree", "reasoning"}
|
|
44
|
+
Generator = Callable[[Any, Any], dict]
|
|
45
|
+
# challenger(payload) -> {"concerns", "agree", "reasoning"}
|
|
46
|
+
Challenger = Callable[[Any], dict]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _default_delta(a: Any, b: Any) -> bool:
|
|
50
|
+
return a != b # True == "changed"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_convergence(
|
|
54
|
+
generator: Generator,
|
|
55
|
+
challenger: Challenger,
|
|
56
|
+
cap: int,
|
|
57
|
+
*,
|
|
58
|
+
integrity: Callable[[Any, list[ConvergenceRound]], bool] | None = None,
|
|
59
|
+
reopen_cap: int = 1,
|
|
60
|
+
delta: Callable[[Any, Any], bool] | None = None,
|
|
61
|
+
log: Callable[[str], None] = lambda _m: None,
|
|
62
|
+
) -> ConvergenceResult:
|
|
63
|
+
delta = delta or _default_delta
|
|
64
|
+
rounds: list[ConvergenceRound] = []
|
|
65
|
+
reopened = 0
|
|
66
|
+
|
|
67
|
+
gen = generator(None, None)
|
|
68
|
+
first_payload = gen.get("payload")
|
|
69
|
+
|
|
70
|
+
for i in range(cap):
|
|
71
|
+
chal = challenger(gen.get("payload"))
|
|
72
|
+
both = bool(gen.get("agree")) and bool(chal.get("agree"))
|
|
73
|
+
rounds.append(ConvergenceRound(i + 1, gen, chal, both))
|
|
74
|
+
|
|
75
|
+
if both:
|
|
76
|
+
if integrity is not None and reopened < reopen_cap and not integrity(gen.get("payload"), rounds):
|
|
77
|
+
reopened += 1
|
|
78
|
+
log(f"convergence-integrity: reopened phase (premature) [{reopened}/{reopen_cap}]")
|
|
79
|
+
gen = generator(gen.get("payload"), chal.get("concerns"))
|
|
80
|
+
continue
|
|
81
|
+
no_op = not delta(first_payload, gen.get("payload"))
|
|
82
|
+
return ConvergenceResult(True, gen.get("payload"), rounds, i + 1, no_op, reopened)
|
|
83
|
+
|
|
84
|
+
# Only revise if another challenge round follows. A final, un-challenged
|
|
85
|
+
# revision must NOT become the committed payload: the phase exits on the
|
|
86
|
+
# option the Challenger actually last evaluated, so the committed option
|
|
87
|
+
# and its standing objections (rounds[-1].challenger) refer to the SAME
|
|
88
|
+
# payload — no committed-vs-objection mismatch.
|
|
89
|
+
if i < cap - 1:
|
|
90
|
+
gen = generator(gen.get("payload"), chal.get("concerns"))
|
|
91
|
+
|
|
92
|
+
no_op = not delta(first_payload, gen.get("payload"))
|
|
93
|
+
return ConvergenceResult(False, gen.get("payload"), rounds, None, no_op, reopened)
|
rapier/envelope.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The Envelope — the single typed state object that flows through the pipeline.
|
|
2
|
+
|
|
3
|
+
Every stage reads and writes one Envelope. Nothing else is passed between
|
|
4
|
+
stages. Get this contract right and stages become independently swappable:
|
|
5
|
+
a stage is just ``run(envelope) -> envelope``.
|
|
6
|
+
|
|
7
|
+
The Envelope carries the whole ceremony: the request in, the Proposer's
|
|
8
|
+
evolving option space, the committed option, the Resolver's recommendation and
|
|
9
|
+
trust rider, the accumulated grounding artifacts, and an append-only trace that
|
|
10
|
+
feeds the ledger/audit sink.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TraceEntry:
|
|
21
|
+
"""One append-only record of what a stage did. Feeds the audit ledger."""
|
|
22
|
+
|
|
23
|
+
stage: str
|
|
24
|
+
kind: str
|
|
25
|
+
summary: str
|
|
26
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
ts: float = field(default_factory=time.time)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Artifact:
|
|
32
|
+
"""A checkable grounding claim raised during the ceremony.
|
|
33
|
+
|
|
34
|
+
``ref`` is the bare checkable token (a CWE id, a DOI, a ``path:line``, a
|
|
35
|
+
``#3`` pack-fact pointer, a URL). ``verdict`` is set by the verification
|
|
36
|
+
service in later milestones.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
ref: str
|
|
40
|
+
claim: str
|
|
41
|
+
load_bearing: bool = False
|
|
42
|
+
verdict: str = "unchecked" # unchecked | verified | refuted | unverifiable
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Envelope:
|
|
47
|
+
"""The state passed through every stage of a Rapier pipeline."""
|
|
48
|
+
|
|
49
|
+
request: str
|
|
50
|
+
options: list[str] = field(default_factory=list)
|
|
51
|
+
committed: str | None = None
|
|
52
|
+
recommendation: str | None = None
|
|
53
|
+
trust_rider: dict[str, Any] | None = None
|
|
54
|
+
artifacts: list[Artifact] = field(default_factory=list)
|
|
55
|
+
trace: list[TraceEntry] = field(default_factory=list)
|
|
56
|
+
verdict: str | None = None
|
|
57
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
def add_trace(self, stage: str, kind: str, summary: str, **data: Any) -> "Envelope":
|
|
60
|
+
"""Append an audit trace entry and return self (for chaining)."""
|
|
61
|
+
self.trace.append(TraceEntry(stage=stage, kind=kind, summary=summary, data=data))
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
"""Plain-dict view for serialization (redacted before it is persisted)."""
|
|
66
|
+
return asdict(self)
|
rapier/ledger.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The audit sink — persists a run's trace and envelope to a run directory.
|
|
2
|
+
|
|
3
|
+
Every ceremony leaves a re-readable log on disk (the framework's auditability
|
|
4
|
+
discipline). Two security properties, both part of the M0 exit criterion:
|
|
5
|
+
|
|
6
|
+
* Everything written is passed through :func:`rapier.secrets.redact_obj` first.
|
|
7
|
+
* Files and directories are created owner-only (0600 / 0700).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from .envelope import Envelope
|
|
17
|
+
from .secrets import redact_obj
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _slug(text: str) -> str:
|
|
21
|
+
text = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
|
|
22
|
+
return text[:40] or "run"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Ledger:
|
|
26
|
+
def __init__(self, root: str, run_slug: str = "run"):
|
|
27
|
+
stamp = time.strftime("%Y%m%d%H%M%S")
|
|
28
|
+
self.run_dir = os.path.join(root, f"{stamp}-{_slug(run_slug)}")
|
|
29
|
+
os.makedirs(self.run_dir, mode=0o700, exist_ok=True)
|
|
30
|
+
try:
|
|
31
|
+
os.chmod(self.run_dir, 0o700)
|
|
32
|
+
except OSError: # pragma: no cover - platform dependent
|
|
33
|
+
pass
|
|
34
|
+
self.ledger_path = os.path.join(self.run_dir, "ledger.jsonl")
|
|
35
|
+
|
|
36
|
+
def _open_owner_only(self, path: str, append: bool) -> int:
|
|
37
|
+
flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_TRUNC)
|
|
38
|
+
return os.open(path, flags, 0o600)
|
|
39
|
+
|
|
40
|
+
def _write(self, name: str, obj) -> str:
|
|
41
|
+
path = os.path.join(self.run_dir, name)
|
|
42
|
+
payload = json.dumps(redact_obj(obj), indent=2, default=str)
|
|
43
|
+
with os.fdopen(self._open_owner_only(path, append=False), "w") as fh:
|
|
44
|
+
fh.write(payload)
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
def record_stage(self, stage_name: str, entry: dict) -> None:
|
|
48
|
+
line = json.dumps(redact_obj({"stage": stage_name, **entry}), default=str)
|
|
49
|
+
with os.fdopen(self._open_owner_only(self.ledger_path, append=True), "a") as fh:
|
|
50
|
+
fh.write(line + "\n")
|
|
51
|
+
|
|
52
|
+
def write_text(self, name: str, text: str) -> str:
|
|
53
|
+
"""Write a redacted text/markdown artifact (owner-only) to the run dir."""
|
|
54
|
+
path = os.path.join(self.run_dir, name)
|
|
55
|
+
with os.fdopen(self._open_owner_only(path, append=False), "w") as fh:
|
|
56
|
+
fh.write(redact_obj(text))
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
def write_json(self, name: str, obj) -> str:
|
|
60
|
+
"""Write a redacted JSON artifact (owner-only) to the run dir."""
|
|
61
|
+
return self._write(name, obj)
|
|
62
|
+
|
|
63
|
+
def record_transcript(self, event: dict) -> None:
|
|
64
|
+
"""Append one verbatim model-call record (redacted) to transcript.jsonl."""
|
|
65
|
+
path = os.path.join(self.run_dir, "transcript.jsonl")
|
|
66
|
+
line = json.dumps(redact_obj(event), default=str)
|
|
67
|
+
with os.fdopen(self._open_owner_only(path, append=True), "a") as fh:
|
|
68
|
+
fh.write(line + "\n")
|
|
69
|
+
|
|
70
|
+
def persist_envelope(self, env: Envelope) -> str:
|
|
71
|
+
return self._write("envelope.json", env.to_dict())
|
rapier/manifest.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The manifest loader — the method as declarative data.
|
|
2
|
+
|
|
3
|
+
A manifest is a YAML file that lists the stages of a pipeline, each with its
|
|
4
|
+
config and its role→model bindings. Editing the manifest changes the *method*
|
|
5
|
+
without touching the engine; that is what makes the method modular and
|
|
6
|
+
upgradeable (swap a model, reorder a stage, add a module).
|
|
7
|
+
|
|
8
|
+
Parsed with ``yaml.safe_load`` — never ``load`` — so a manifest can never
|
|
9
|
+
execute arbitrary Python (threat model: untrusted config / deserialization).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .models import ModelSpec, Policy
|
|
19
|
+
from .pipeline import Pipeline, StageSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Manifest:
|
|
24
|
+
name: str
|
|
25
|
+
stages: list[StageSpec]
|
|
26
|
+
policy: Policy | None = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def load(cls, path: str) -> "Manifest":
|
|
30
|
+
with open(path, encoding="utf-8") as fh:
|
|
31
|
+
data = yaml.safe_load(fh)
|
|
32
|
+
if not isinstance(data, dict):
|
|
33
|
+
raise ValueError("manifest must be a YAML mapping at the top level")
|
|
34
|
+
return cls.from_dict(data)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict[str, Any]) -> "Manifest":
|
|
38
|
+
name = data.get("name", "pipeline")
|
|
39
|
+
raw_stages = data.get("pipeline")
|
|
40
|
+
if not isinstance(raw_stages, list) or not raw_stages:
|
|
41
|
+
raise ValueError("manifest 'pipeline' must be a non-empty list")
|
|
42
|
+
|
|
43
|
+
stages: list[StageSpec] = []
|
|
44
|
+
for i, entry in enumerate(raw_stages):
|
|
45
|
+
if not isinstance(entry, dict) or "stage" not in entry:
|
|
46
|
+
raise ValueError(f"pipeline[{i}] must be a mapping with a 'stage' key")
|
|
47
|
+
roles: dict[str, ModelSpec] = {}
|
|
48
|
+
for role, binding in (entry.get("roles") or {}).items():
|
|
49
|
+
if "vendor" not in binding or "model" not in binding:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"pipeline[{i}].roles.{role} needs both 'vendor' and 'model'"
|
|
52
|
+
)
|
|
53
|
+
roles[role] = ModelSpec(
|
|
54
|
+
vendor=binding["vendor"],
|
|
55
|
+
model=binding["model"],
|
|
56
|
+
prompt_template=binding.get("prompt"),
|
|
57
|
+
max_tokens=binding.get("max_tokens", 4096),
|
|
58
|
+
temperature=binding.get("temperature", 1.0),
|
|
59
|
+
)
|
|
60
|
+
stages.append(
|
|
61
|
+
StageSpec(stage=entry["stage"], config=entry.get("config") or {}, roles=roles)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
policy = None
|
|
65
|
+
pol = data.get("policy")
|
|
66
|
+
if isinstance(pol, dict):
|
|
67
|
+
policy = Policy(
|
|
68
|
+
vendors=pol.get("vendors"),
|
|
69
|
+
independence=pol.get("independence", "preferred"),
|
|
70
|
+
avoid_jurisdictions=pol.get("avoid_jurisdictions") or [],
|
|
71
|
+
)
|
|
72
|
+
return cls(name=name, stages=stages, policy=policy)
|
|
73
|
+
|
|
74
|
+
def build(self) -> Pipeline:
|
|
75
|
+
return Pipeline.from_manifest(self)
|
rapier/mcp/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Rapier's MCP server (optional — needs the ``mcp`` extra).
|
|
2
|
+
|
|
3
|
+
``pip install "rapier-runtime[mcp]"`` then run ``rapier mcp``. The SDK is imported
|
|
4
|
+
lazily inside :func:`build_server`, so importing this package (and the rest of the
|
|
5
|
+
CLI) never requires the extra.
|
|
6
|
+
"""
|
|
7
|
+
from .server import build_server, serve
|
|
8
|
+
|
|
9
|
+
__all__ = ["build_server", "serve"]
|