proofside 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.
- proofside/__init__.py +3 -0
- proofside/__main__.py +6 -0
- proofside/acceptance.py +77 -0
- proofside/artifacts.py +9 -0
- proofside/batch.py +309 -0
- proofside/cli.py +423 -0
- proofside/contracts.py +372 -0
- proofside/proposal.py +290 -0
- proofside/specification.py +129 -0
- proofside-0.1.0.dist-info/METADATA +373 -0
- proofside-0.1.0.dist-info/RECORD +15 -0
- proofside-0.1.0.dist-info/WHEEL +5 -0
- proofside-0.1.0.dist-info/entry_points.txt +2 -0
- proofside-0.1.0.dist-info/licenses/LICENSE +202 -0
- proofside-0.1.0.dist-info/top_level.txt +1 -0
proofside/cli.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .artifacts import candidate_contract_path
|
|
13
|
+
from .contracts import (
|
|
14
|
+
ContractError,
|
|
15
|
+
build_annotated_source,
|
|
16
|
+
load_contract,
|
|
17
|
+
render_human,
|
|
18
|
+
validate_contract,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Status(str, Enum):
|
|
23
|
+
VERIFIED = "VERIFIED"
|
|
24
|
+
FAILED = "FAILED"
|
|
25
|
+
UNSUPPORTED = "UNSUPPORTED"
|
|
26
|
+
ERROR = "ERROR"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CheckResult:
|
|
31
|
+
status: Status
|
|
32
|
+
detail: str
|
|
33
|
+
contract_text: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_selector(selector: str) -> tuple[Path, str]:
|
|
37
|
+
if selector.count("::") != 1:
|
|
38
|
+
raise ValueError("selector must have the form path/to/file.py::function_name")
|
|
39
|
+
|
|
40
|
+
file_text, function_name = selector.split("::")
|
|
41
|
+
if not file_text or Path(file_text).suffix != ".py":
|
|
42
|
+
raise ValueError("selector must name a Python file ending in .py")
|
|
43
|
+
if not function_name.isidentifier():
|
|
44
|
+
raise ValueError("selector must end with one unqualified function name")
|
|
45
|
+
return Path(file_text), function_name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _function_arguments(function: ast.FunctionDef) -> list[ast.arg]:
|
|
49
|
+
arguments = [*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs]
|
|
50
|
+
if function.args.vararg:
|
|
51
|
+
arguments.append(function.args.vararg)
|
|
52
|
+
if function.args.kwarg:
|
|
53
|
+
arguments.append(function.args.kwarg)
|
|
54
|
+
return arguments
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_target(
|
|
58
|
+
file_path: Path,
|
|
59
|
+
function_name: str,
|
|
60
|
+
require_inline_contract: bool = True,
|
|
61
|
+
) -> tuple[str, ast.FunctionDef] | CheckResult:
|
|
62
|
+
if not file_path.is_file():
|
|
63
|
+
return CheckResult(Status.ERROR, f"file does not exist: {file_path}")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
source = file_path.read_text(encoding="utf-8")
|
|
67
|
+
except OSError as error:
|
|
68
|
+
return CheckResult(Status.ERROR, f"could not read {file_path}: {error}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
72
|
+
except SyntaxError as error:
|
|
73
|
+
location = f"line {error.lineno}" if error.lineno else "unknown line"
|
|
74
|
+
return CheckResult(Status.ERROR, f"cannot parse {file_path} ({location}): {error.msg}")
|
|
75
|
+
|
|
76
|
+
functions = [
|
|
77
|
+
node for node in tree.body
|
|
78
|
+
if isinstance(node, ast.FunctionDef) and node.name == function_name
|
|
79
|
+
]
|
|
80
|
+
async_functions = [
|
|
81
|
+
node for node in tree.body
|
|
82
|
+
if isinstance(node, ast.AsyncFunctionDef) and node.name == function_name
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
if len(functions) + len(async_functions) > 1:
|
|
86
|
+
return CheckResult(Status.UNSUPPORTED, f"multiple top-level functions are named {function_name}")
|
|
87
|
+
if async_functions:
|
|
88
|
+
return CheckResult(Status.UNSUPPORTED, "async functions are not supported")
|
|
89
|
+
if not functions:
|
|
90
|
+
nested_match = any(
|
|
91
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
92
|
+
and node.name == function_name
|
|
93
|
+
for node in ast.walk(tree)
|
|
94
|
+
)
|
|
95
|
+
if nested_match:
|
|
96
|
+
return CheckResult(
|
|
97
|
+
Status.UNSUPPORTED,
|
|
98
|
+
"the selected function is nested or is a method; only top-level functions are supported",
|
|
99
|
+
)
|
|
100
|
+
return CheckResult(Status.ERROR, f"top-level function not found: {function_name}")
|
|
101
|
+
|
|
102
|
+
function = functions[0]
|
|
103
|
+
if function.decorator_list:
|
|
104
|
+
return CheckResult(Status.UNSUPPORTED, "decorated functions are not supported")
|
|
105
|
+
|
|
106
|
+
arguments = _function_arguments(function)
|
|
107
|
+
if function.returns is None or any(argument.annotation is None for argument in arguments):
|
|
108
|
+
return CheckResult(Status.UNSUPPORTED, "the function must have complete parameter and return annotations")
|
|
109
|
+
|
|
110
|
+
if require_inline_contract:
|
|
111
|
+
contract_names = {"Requires", "Ensures"}
|
|
112
|
+
has_contract = any(
|
|
113
|
+
isinstance(statement, ast.Expr)
|
|
114
|
+
and isinstance(statement.value, ast.Call)
|
|
115
|
+
and isinstance(statement.value.func, ast.Name)
|
|
116
|
+
and statement.value.func.id in contract_names
|
|
117
|
+
for statement in function.body
|
|
118
|
+
)
|
|
119
|
+
if not has_contract:
|
|
120
|
+
return CheckResult(Status.UNSUPPORTED, "no direct Requires or Ensures contract call was found")
|
|
121
|
+
|
|
122
|
+
return source, function
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def classify_nagini(return_code: int, stdout: str, stderr: str) -> CheckResult:
|
|
126
|
+
if return_code == 0 and "Verification successful" in stdout:
|
|
127
|
+
return CheckResult(Status.VERIFIED, "Nagini/Viper discharged the declared proof obligations.")
|
|
128
|
+
|
|
129
|
+
if return_code != 0 and stdout.startswith("Verification failed"):
|
|
130
|
+
diagnostic_lines = stdout.splitlines()[2:]
|
|
131
|
+
if diagnostic_lines and diagnostic_lines[-1].startswith("Verification took "):
|
|
132
|
+
diagnostic_lines.pop()
|
|
133
|
+
return CheckResult(Status.FAILED, "\n".join(diagnostic_lines).strip())
|
|
134
|
+
|
|
135
|
+
diagnostic = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
|
|
136
|
+
return CheckResult(Status.ERROR, diagnostic or f"Nagini exited unexpectedly with status {return_code}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run_nagini(file_path: Path, function_name: str) -> CheckResult:
|
|
140
|
+
nagini = shutil.which("nagini")
|
|
141
|
+
if not nagini:
|
|
142
|
+
return CheckResult(Status.ERROR, "Nagini executable not found; activate the verification environment")
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
completed = subprocess.run(
|
|
146
|
+
[
|
|
147
|
+
nagini,
|
|
148
|
+
"--verifier",
|
|
149
|
+
"silicon",
|
|
150
|
+
"--select",
|
|
151
|
+
function_name,
|
|
152
|
+
str(file_path.resolve()),
|
|
153
|
+
],
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
check=False,
|
|
157
|
+
)
|
|
158
|
+
except OSError as error:
|
|
159
|
+
return CheckResult(Status.ERROR, f"could not start Nagini: {error}")
|
|
160
|
+
|
|
161
|
+
return classify_nagini(completed.returncode, completed.stdout, completed.stderr)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check(selector: str, contract_path: Path | None = None) -> CheckResult:
|
|
165
|
+
try:
|
|
166
|
+
file_path, function_name = parse_selector(selector)
|
|
167
|
+
except ValueError as error:
|
|
168
|
+
return CheckResult(Status.ERROR, str(error))
|
|
169
|
+
|
|
170
|
+
target = load_target(file_path, function_name, require_inline_contract=contract_path is None)
|
|
171
|
+
if isinstance(target, CheckResult):
|
|
172
|
+
return target
|
|
173
|
+
source, function = target
|
|
174
|
+
|
|
175
|
+
if contract_path is None:
|
|
176
|
+
return run_nagini(file_path, function_name)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
contract = load_contract(contract_path)
|
|
180
|
+
parameters = {argument.arg for argument in _function_arguments(function)}
|
|
181
|
+
validate_contract(contract, parameters)
|
|
182
|
+
contract_text = render_human(contract)
|
|
183
|
+
annotated_source = build_annotated_source(source, function, contract)
|
|
184
|
+
except ContractError as error:
|
|
185
|
+
return CheckResult(Status.ERROR, f"invalid contract: {error}")
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with tempfile.TemporaryDirectory(prefix="proofside-") as directory:
|
|
189
|
+
verification_path = Path(directory, f"{file_path.stem}_verification.py")
|
|
190
|
+
verification_path.write_text(annotated_source, encoding="utf-8")
|
|
191
|
+
result = run_nagini(verification_path, function_name)
|
|
192
|
+
except OSError as error:
|
|
193
|
+
return CheckResult(Status.ERROR, f"could not create temporary verification source: {error}")
|
|
194
|
+
|
|
195
|
+
return CheckResult(result.status, result.detail, contract_text)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render_proof_boundary(result: CheckResult) -> str:
|
|
199
|
+
if result.contract_text is None:
|
|
200
|
+
return ""
|
|
201
|
+
if result.status is Status.VERIFIED:
|
|
202
|
+
lines = [
|
|
203
|
+
"Proof boundary",
|
|
204
|
+
"- The selected implementation satisfies the displayed guarantees under the displayed assumptions, according to Nagini/Viper.",
|
|
205
|
+
"- Preconditions remain assumptions about valid callers and inputs.",
|
|
206
|
+
"- The proof covers only the displayed contract and the supported verification semantics.",
|
|
207
|
+
"- It does not establish scientific validity, empirical correspondence, usefulness, or optimality.",
|
|
208
|
+
]
|
|
209
|
+
return "\n".join(lines)
|
|
210
|
+
if result.status is Status.FAILED:
|
|
211
|
+
lines = [
|
|
212
|
+
"Proof boundary",
|
|
213
|
+
"- One or more displayed proof obligations were not established.",
|
|
214
|
+
"- FAILED does not by itself mean that a concrete counterexample was produced.",
|
|
215
|
+
"- No conclusion about scientific validity follows from this failed proof attempt.",
|
|
216
|
+
]
|
|
217
|
+
return "\n".join(lines)
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def render_output(result: CheckResult) -> str:
|
|
222
|
+
sections = []
|
|
223
|
+
if result.contract_text:
|
|
224
|
+
sections.append(f"Contract\n\n{result.contract_text}")
|
|
225
|
+
status = result.status.value
|
|
226
|
+
if result.detail:
|
|
227
|
+
status += f"\n{result.detail}"
|
|
228
|
+
sections.append(status)
|
|
229
|
+
boundary = render_proof_boundary(result)
|
|
230
|
+
if boundary:
|
|
231
|
+
sections.append(boundary)
|
|
232
|
+
return "\n\n".join(sections)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(argv: list[str] | None = None) -> int:
|
|
236
|
+
parser = argparse.ArgumentParser(
|
|
237
|
+
prog="proofside",
|
|
238
|
+
description="State the math, review the contract, and verify typed Python.",
|
|
239
|
+
)
|
|
240
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
241
|
+
check_parser = subparsers.add_parser(
|
|
242
|
+
"check",
|
|
243
|
+
help="verify one contracted top-level function",
|
|
244
|
+
description=(
|
|
245
|
+
"Verify one typed top-level function with a JSON sidecar contract, "
|
|
246
|
+
"or with handwritten Nagini contracts when --contract is omitted."
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
check_parser.add_argument("selector", help="path/to/file.py::function_name")
|
|
250
|
+
check_parser.add_argument(
|
|
251
|
+
"--contract",
|
|
252
|
+
type=Path,
|
|
253
|
+
help="structured JSON sidecar; omit for handwritten Nagini contracts",
|
|
254
|
+
)
|
|
255
|
+
check_all_parser = subparsers.add_parser(
|
|
256
|
+
"check-all",
|
|
257
|
+
help="verify Proofside-marked functions using source-adjacent contracts",
|
|
258
|
+
description=(
|
|
259
|
+
"Independently verify marked functions with accepted source-adjacent "
|
|
260
|
+
"contracts. This command does not propose or accept contracts."
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
check_all_parser.add_argument("targets", nargs="+", type=Path, help="Python files or directories")
|
|
264
|
+
check_all_parser.add_argument(
|
|
265
|
+
"--allow-unreviewed",
|
|
266
|
+
action="store_true",
|
|
267
|
+
help="fall back to candidate contracts; still returns a nonzero exit status",
|
|
268
|
+
)
|
|
269
|
+
propose_parser = subparsers.add_parser(
|
|
270
|
+
"propose",
|
|
271
|
+
help="ask an explicitly selected model for an unverified candidate contract",
|
|
272
|
+
description=(
|
|
273
|
+
"Ask an explicitly selected model for one unverified candidate. "
|
|
274
|
+
"This command never accepts the candidate or runs verification."
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
propose_parser.add_argument("selector", help="path/to/file.py::function_name")
|
|
278
|
+
propose_parser.add_argument(
|
|
279
|
+
"--model-source",
|
|
280
|
+
choices=("api", "local"),
|
|
281
|
+
required=True,
|
|
282
|
+
help="api sends selected context remotely; local sends it to the chosen local endpoint",
|
|
283
|
+
)
|
|
284
|
+
propose_parser.add_argument("--model", required=True, help="explicit model name")
|
|
285
|
+
propose_parser.add_argument(
|
|
286
|
+
"--out",
|
|
287
|
+
type=Path,
|
|
288
|
+
help="new candidate JSON path; defaults beside the source in .proofside/",
|
|
289
|
+
)
|
|
290
|
+
propose_parser.add_argument(
|
|
291
|
+
"--base-url",
|
|
292
|
+
help="OpenAI-compatible base URL; API is remote and local defaults to localhost",
|
|
293
|
+
)
|
|
294
|
+
propose_parser.add_argument(
|
|
295
|
+
"--api-key-env",
|
|
296
|
+
help="credential environment-variable name required for a custom API base URL",
|
|
297
|
+
)
|
|
298
|
+
propose_parser.add_argument(
|
|
299
|
+
"--source",
|
|
300
|
+
action="append",
|
|
301
|
+
choices=("equation", "intent", "implementation"),
|
|
302
|
+
help="repeatable specification source; defaults to available equation/intent annotations",
|
|
303
|
+
)
|
|
304
|
+
propose_all_parser = subparsers.add_parser(
|
|
305
|
+
"propose-all",
|
|
306
|
+
help="propose candidates with up to one model request per marked function",
|
|
307
|
+
description=(
|
|
308
|
+
"Independently propose unverified candidates for marked functions, "
|
|
309
|
+
"using deterministic source-adjacent paths. Nothing is accepted or verified."
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
propose_all_parser.add_argument("targets", nargs="+", type=Path, help="Python files or directories")
|
|
313
|
+
propose_all_parser.add_argument(
|
|
314
|
+
"--model-source",
|
|
315
|
+
choices=("api", "local"),
|
|
316
|
+
required=True,
|
|
317
|
+
help="api sends selected context remotely; local sends it to the chosen local endpoint",
|
|
318
|
+
)
|
|
319
|
+
propose_all_parser.add_argument("--model", required=True, help="explicit model name")
|
|
320
|
+
propose_all_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
|
|
321
|
+
propose_all_parser.add_argument(
|
|
322
|
+
"--api-key-env",
|
|
323
|
+
help="credential environment-variable name required for a custom API base URL",
|
|
324
|
+
)
|
|
325
|
+
propose_all_parser.add_argument(
|
|
326
|
+
"--source",
|
|
327
|
+
action="append",
|
|
328
|
+
choices=("equation", "intent", "implementation"),
|
|
329
|
+
help="repeatable specification source; defaults per function to equation/intent annotations",
|
|
330
|
+
)
|
|
331
|
+
accept_parser = subparsers.add_parser(
|
|
332
|
+
"accept",
|
|
333
|
+
help="explicitly accept a candidate contract for later verification",
|
|
334
|
+
description=(
|
|
335
|
+
"Validate a candidate and record it as accepted for verification. "
|
|
336
|
+
"Acceptance does not assert correctness or run verification."
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
accept_parser.add_argument("selector", help="path/to/file.py::function_name")
|
|
340
|
+
accept_parser.add_argument(
|
|
341
|
+
"--candidate",
|
|
342
|
+
type=Path,
|
|
343
|
+
help="candidate JSON path; defaults beside the source in .proofside/",
|
|
344
|
+
)
|
|
345
|
+
accept_parser.add_argument(
|
|
346
|
+
"--replace",
|
|
347
|
+
action="store_true",
|
|
348
|
+
help="replace an existing accepted contract after successful validation",
|
|
349
|
+
)
|
|
350
|
+
arguments = parser.parse_args(argv)
|
|
351
|
+
|
|
352
|
+
if arguments.command == "check":
|
|
353
|
+
result = check(arguments.selector, arguments.contract)
|
|
354
|
+
print(render_output(result))
|
|
355
|
+
return 0 if result.status is Status.VERIFIED else 1
|
|
356
|
+
|
|
357
|
+
if arguments.command == "check-all":
|
|
358
|
+
from .batch import batch_succeeded, render_batch_output, run_batch_checks
|
|
359
|
+
|
|
360
|
+
results, discovery_errors = run_batch_checks(
|
|
361
|
+
tuple(arguments.targets),
|
|
362
|
+
arguments.allow_unreviewed,
|
|
363
|
+
)
|
|
364
|
+
print(render_batch_output(results, discovery_errors))
|
|
365
|
+
return 0 if batch_succeeded(results, discovery_errors) else 1
|
|
366
|
+
|
|
367
|
+
if arguments.command == "propose-all":
|
|
368
|
+
from .batch import (
|
|
369
|
+
batch_proposal_succeeded,
|
|
370
|
+
render_batch_proposal_output,
|
|
371
|
+
run_batch_proposals,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
results, discovery_errors = run_batch_proposals(
|
|
375
|
+
tuple(arguments.targets),
|
|
376
|
+
arguments.model_source,
|
|
377
|
+
arguments.model,
|
|
378
|
+
arguments.base_url,
|
|
379
|
+
arguments.api_key_env,
|
|
380
|
+
tuple(arguments.source) if arguments.source else None,
|
|
381
|
+
)
|
|
382
|
+
print(render_batch_proposal_output(results, discovery_errors))
|
|
383
|
+
return 0 if batch_proposal_succeeded(results, discovery_errors) else 1
|
|
384
|
+
|
|
385
|
+
if arguments.command == "accept":
|
|
386
|
+
from .acceptance import AcceptanceError, accept_contract, render_acceptance_output
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
contract_text, candidate_path, accepted_path = accept_contract(
|
|
390
|
+
arguments.selector,
|
|
391
|
+
arguments.candidate,
|
|
392
|
+
arguments.replace,
|
|
393
|
+
)
|
|
394
|
+
except AcceptanceError as error:
|
|
395
|
+
print("ACCEPTANCE REJECTED")
|
|
396
|
+
print(error)
|
|
397
|
+
return 1
|
|
398
|
+
print(render_acceptance_output(contract_text, candidate_path, accepted_path))
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
from .proposal import ProposalError, propose_contract, render_proposal_output
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
contract_text, sources = propose_contract(
|
|
405
|
+
arguments.selector,
|
|
406
|
+
arguments.model_source,
|
|
407
|
+
arguments.model,
|
|
408
|
+
arguments.out,
|
|
409
|
+
arguments.base_url,
|
|
410
|
+
arguments.api_key_env,
|
|
411
|
+
tuple(arguments.source) if arguments.source else None,
|
|
412
|
+
)
|
|
413
|
+
except ProposalError as error:
|
|
414
|
+
print("PROPOSAL REJECTED")
|
|
415
|
+
print(error)
|
|
416
|
+
return 1
|
|
417
|
+
output_path = arguments.out
|
|
418
|
+
if output_path is None:
|
|
419
|
+
file_path, function_name = parse_selector(arguments.selector)
|
|
420
|
+
output_path = candidate_contract_path(file_path, function_name)
|
|
421
|
+
print(render_proposal_output(arguments.selector, output_path, contract_text, sources))
|
|
422
|
+
return 0
|
|
423
|
+
|