comuvia 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.
comuvia/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """Comuvia: offline, provider-neutral evidence and prediction records.
2
+
3
+ Implemented: the nine comuvia 0.1 record types as packaged JSON Schemas, strict parsing,
4
+ single-record validation with named refusal codes, RFC 8785 canonicalization, content and
5
+ record digests, identity and selector verification, a single-writer append-only record
6
+ store, eligibility and the binary-brier-v1 rule with named exclusions, reproduction of
7
+ stored evaluations, and the ``comuvia`` command-line interface. There is no aggregate
8
+ score, no network access and no runtime dependency.
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from .identity import (
14
+ ReferenceCheck,
15
+ Verification,
16
+ canonicalize,
17
+ content_digest,
18
+ is_marker,
19
+ parse_json,
20
+ record_digest,
21
+ reference_to,
22
+ revision_conflicts,
23
+ verify,
24
+ )
25
+ from .evaluation import (
26
+ CandidateResult,
27
+ EvaluationReport,
28
+ Reproduction,
29
+ ScoringRule,
30
+ evaluate,
31
+ evaluation_records,
32
+ load_rule,
33
+ reproduce,
34
+ )
35
+ from .history import latest_revisions
36
+ from .issues import Issue, RefusalError
37
+ from .selectors import SelectorCheck, check_selector
38
+ from .store import RecordStore, StoreEntry, StoreReport
39
+ from .validation import RECORD_TYPES, SCHEMA_VERSION, load_record, schema_files, validate
40
+
41
+ __all__ = [
42
+ "CandidateResult",
43
+ "EvaluationReport",
44
+ "Issue",
45
+ "RECORD_TYPES",
46
+ "RecordStore",
47
+ "ReferenceCheck",
48
+ "RefusalError",
49
+ "Reproduction",
50
+ "SCHEMA_VERSION",
51
+ "ScoringRule",
52
+ "SelectorCheck",
53
+ "StoreEntry",
54
+ "StoreReport",
55
+ "Verification",
56
+ "canonicalize",
57
+ "check_selector",
58
+ "content_digest",
59
+ "evaluate",
60
+ "evaluation_records",
61
+ "is_marker",
62
+ "latest_revisions",
63
+ "load_record",
64
+ "load_rule",
65
+ "parse_json",
66
+ "record_digest",
67
+ "reference_to",
68
+ "reproduce",
69
+ "revision_conflicts",
70
+ "schema_files",
71
+ "validate",
72
+ "verify",
73
+ ]
comuvia/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run the comuvia command-line interface: python -m comuvia."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
comuvia/cli.py ADDED
@@ -0,0 +1,343 @@
1
+ """The ``comuvia`` command-line interface (standard library ``argparse``; no network command).
2
+
3
+ Exit codes: 0 success (exclusions are results, not errors); 1 refusal or mismatch; 2 usage error.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import datetime as _dt
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .evaluation import evaluate, evaluation_records, reproduce
16
+ from .identity import content_digest, record_digest, verify
17
+ from .issues import Issue, RefusalError
18
+ from .selectors import check_selector
19
+ from .store import RecordStore
20
+ from .validation import instant, load_record, validate
21
+
22
+ OK, FAILED, USAGE = 0, 1, 2
23
+
24
+
25
+ class _Output:
26
+ def __init__(self, as_json: bool):
27
+ self.as_json = as_json
28
+
29
+ def emit(self, data: dict, lines: list[str]) -> None:
30
+ if self.as_json:
31
+ print(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True))
32
+ else:
33
+ for line in lines:
34
+ print(line)
35
+
36
+
37
+ def _issues(issues) -> list[dict]:
38
+ return [{"code": i.code, "pointer": i.pointer, "message": i.message} for i in issues]
39
+
40
+
41
+ def _issue_lines(issues, indent: str = " ") -> list[str]:
42
+ return [f"{indent}{i.code} at '{i.pointer}': {i.message}" for i in issues]
43
+
44
+
45
+ def _read_record(path: str) -> dict:
46
+ return load_record(Path(path).read_bytes())
47
+
48
+
49
+ def _now() -> str:
50
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
51
+
52
+
53
+ # ------------------------------------------------------------------ commands
54
+
55
+
56
+ def cmd_validate(args, out: _Output) -> int:
57
+ results, lines, failed = [], [], False
58
+ for path in args.files:
59
+ try:
60
+ body = _read_record(path)
61
+ results.append({"file": path, "valid": True, "record_digest": record_digest(body), "issues": []})
62
+ lines.append(f"valid {path} {record_digest(body)}")
63
+ except RefusalError as error:
64
+ failed = True
65
+ results.append({"file": path, "valid": False, "issues": _issues(error.issues)})
66
+ lines += [f"REFUSED {path}"] + _issue_lines(error.issues)
67
+ out.emit({"results": results}, lines)
68
+ return FAILED if failed else OK
69
+
70
+
71
+ def cmd_digest(args, out: _Output) -> int:
72
+ data = Path(args.file).read_bytes()
73
+ if args.content:
74
+ digest, kind = content_digest(data), "content"
75
+ else:
76
+ digest, kind = record_digest(load_record(data)), "record"
77
+ out.emit({"kind": kind, "digest": digest, "byte_length": len(data) if args.content else None}, [digest])
78
+ return OK
79
+
80
+
81
+ def cmd_verify(args, out: _Output) -> int:
82
+ body = _read_record(args.record)
83
+ content = Path(args.content).read_bytes() if args.content else None
84
+ records = RecordStore(args.store).records() if args.store else []
85
+ result = verify(body, expected_digest=args.expected_digest, content=content, records=records)
86
+ selector = None
87
+ if body["record_type"] == "source_assertion" and content is not None:
88
+ snapshot = next((r for r in records if r["record_type"] == body["resource"]["record_type"]
89
+ and r["id"] == body["resource"]["id"] and r["revision"] == body["resource"]["revision"]), None)
90
+ selector = check_selector(body, content, snapshot=snapshot)
91
+ issues = list(result.issues) + (list(selector.issues) if selector else [])
92
+ data = {
93
+ "record_digest": result.record_digest,
94
+ "record_identity": result.record_identity,
95
+ "content_identity": result.content_identity,
96
+ "references": [{"pointer": r.pointer, "result": r.result} for r in result.references],
97
+ "selector": None if selector is None else {"result": selector.result, "checked": list(selector.checked)},
98
+ "timestamp_assurance": result.timestamp_assurance,
99
+ "signature": result.signature,
100
+ "issues": _issues(issues),
101
+ }
102
+ lines = [
103
+ f"record digest {result.record_digest}",
104
+ f"record identity {result.record_identity}",
105
+ f"content identity {result.content_identity}",
106
+ ]
107
+ lines += [f"reference {r.pointer:<10} {r.result}" for r in result.references]
108
+ if selector is not None:
109
+ lines.append(f"selector {selector.result} ({', '.join(selector.checked) or 'nothing checkable'})")
110
+ lines += [f"timestamp assurance {result.timestamp_assurance}", f"signature {result.signature}"]
111
+ lines += _issue_lines(issues)
112
+ out.emit(data, lines)
113
+ mismatch = issues or (selector is not None and selector.result == "mismatch")
114
+ return FAILED if mismatch else OK
115
+
116
+
117
+ def cmd_store_init(args, out: _Output) -> int:
118
+ RecordStore.create(args.store)
119
+ out.emit({"store": args.store, "created": True}, [f"created store {args.store}"])
120
+ return OK
121
+
122
+
123
+ def cmd_store_append(args, out: _Output) -> int:
124
+ bodies = [_read_record(path) for path in args.files]
125
+ entries = []
126
+ with RecordStore(args.store, writer=True) as store:
127
+ for path, body in zip(args.files, bodies):
128
+ entry = store.append(body)
129
+ entries.append({"file": path, **entry.as_index(), "appended": entry.appended})
130
+ lines = [
131
+ f"{'appended' if e['appended'] else 'present '} seq={e['sequence']} {e['record_type']} {e['id']} r{e['revision']} {e['digest']}"
132
+ for e in entries
133
+ ]
134
+ out.emit({"entries": entries}, lines)
135
+ return OK
136
+
137
+
138
+ def cmd_store_verify(args, out: _Output) -> int:
139
+ report = RecordStore(args.store).verify()
140
+ data = {
141
+ "ok": report.ok,
142
+ "entries": report.entries,
143
+ "bodies": report.bodies,
144
+ "lock_holder": report.lock_holder,
145
+ "lock_note": "holder details last written by a writer; a reader cannot tell whether that holder is still alive",
146
+ "scope": "identity and references only; run 'comuvia reproduce' to check stored evaluation results",
147
+ "issues": _issues(report.issues),
148
+ "detection_limit": report.detection_limit,
149
+ }
150
+ holder = report.lock_holder or "none recorded"
151
+ lines = [f"{'OK' if report.ok else 'PROBLEMS FOUND'}: {report.entries} index entries, {report.bodies} bodies"]
152
+ lines.append(f"writer details last recorded: {holder} (a reader cannot tell whether that writer is alive)")
153
+ lines += _issue_lines(report.issues)
154
+ lines.append(f"limit: {report.detection_limit}")
155
+ lines.append("scope: identity and references only; run 'comuvia reproduce' to check stored evaluation results")
156
+ out.emit(data, lines)
157
+ return OK if report.ok else FAILED
158
+
159
+
160
+ def cmd_store_show(args, out: _Output) -> int:
161
+ store = RecordStore(args.store)
162
+ if args.latest:
163
+ bodies = [b for _, b in sorted(store.latest().items())]
164
+ else:
165
+ bodies = store.records()
166
+ if args.record_type:
167
+ bodies = [b for b in bodies if b["record_type"] == args.record_type]
168
+ if args.id:
169
+ bodies = [b for b in bodies if b["id"] == args.id]
170
+ rows = [{"record_type": b["record_type"], "id": b["id"], "revision": b["revision"], "digest": record_digest(b)} for b in bodies]
171
+ data = {"records": bodies if args.bodies else rows}
172
+ out.emit(data, [f"{r['record_type']:<20} {r['id']} r{r['revision']} {r['digest']}" for r in rows])
173
+ return OK
174
+
175
+
176
+ def cmd_store_repair(args, out: _Output) -> int:
177
+ with RecordStore(args.store, writer=True) as store:
178
+ removed = store.truncate_torn_tail()
179
+ out.emit({"torn_bytes_removed": removed}, [f"removed {removed} bytes of an incomplete final index line"])
180
+ return OK
181
+
182
+
183
+ def cmd_evaluate(args, out: _Output) -> int:
184
+ if args.append and not args.store:
185
+ raise _Usage("--append requires --store")
186
+ if args.append and (not args.evaluated_at or not args.recorded_by):
187
+ raise _Usage("--append requires --evaluated-at and --recorded-by")
188
+ for name in ("evaluated_at", "recorded_at"):
189
+ value = getattr(args, name)
190
+ if value is not None and instant(value) is None:
191
+ raise _Usage(f"--{name.replace('_', '-')} must be an RFC 3339 timestamp with an offset")
192
+ records = RecordStore(args.store).records() if args.store else [_read_record(p) for p in args.files]
193
+ report = evaluate(records)
194
+ data = report.as_json()
195
+ appended = []
196
+ if args.append:
197
+ bodies = evaluation_records(
198
+ report, records,
199
+ evaluated_at=args.evaluated_at, recorded_by=args.recorded_by, recorded_at=args.recorded_at or _now(),
200
+ )
201
+ with RecordStore(args.store, writer=True) as store:
202
+ for body in bodies:
203
+ entry = store.append(body)
204
+ appended.append({"id": body["id"], "digest": entry.digest, "appended": entry.appended})
205
+ data["evaluation_records"] = appended
206
+ counts = report.counts
207
+ lines = [f"scoring rule {report.scoring_rule['id']} v{report.scoring_rule['version']} {report.scoring_rule['digest']}"]
208
+ lines.append("counts: " + ", ".join(f"{k} {v}" for k, v in counts.items()))
209
+ for r in report.results:
210
+ loss = "" if r.loss is None else f" loss {json.dumps(r.loss)}"
211
+ why = f" ({', '.join(r.reasons)})" if r.reasons else ""
212
+ lines.append(f" {r.status:<10} {r.kind:<9} {r.record['id']} r{r.record['revision']}{loss}{why}")
213
+ lines += [f" {'appended' if a['appended'] else 'present'} evaluation {a['id']}" for a in appended]
214
+ lines.append("no aggregate score is computed")
215
+ out.emit(data, lines)
216
+ return OK
217
+
218
+
219
+ def cmd_reproduce(args, out: _Output) -> int:
220
+ store = RecordStore(args.store)
221
+ records = store.records()
222
+ evaluations = [b for b in records if b["record_type"] == "evaluation"]
223
+ if args.ids:
224
+ evaluations = [b for b in evaluations if b["id"] in set(args.ids)]
225
+ missing = set(args.ids) - {b["id"] for b in evaluations}
226
+ if missing:
227
+ raise _Usage(f"no stored evaluation with id {sorted(missing)}")
228
+ results, lines, failed = [], [], False
229
+ for body in evaluations:
230
+ reproduction = reproduce(body, records)
231
+ failed |= reproduction.result != "match"
232
+ results.append({
233
+ "id": body["id"], "revision": body["revision"], "result": reproduction.result,
234
+ "reason": reproduction.reason, "stored": reproduction.stored, "recomputed": reproduction.recomputed,
235
+ })
236
+ loss = reproduction.stored.get("loss")
237
+ lines.append(f"{reproduction.result:<11} {body['id']} r{body['revision']} loss {json.dumps(loss)}"
238
+ + (f" ({reproduction.reason})" if reproduction.reason else ""))
239
+ out.emit({"results": results}, lines or ["no stored evaluations"])
240
+ return FAILED if failed else OK
241
+
242
+
243
+ # ------------------------------------------------------------------ parser
244
+
245
+
246
+ class _Usage(Exception):
247
+ pass
248
+
249
+
250
+ def build_parser() -> argparse.ArgumentParser:
251
+ # --json is accepted before or after the command; SUPPRESS keeps a subcommand's default
252
+ # from overwriting a value given at the top level.
253
+ common = argparse.ArgumentParser(add_help=False)
254
+ common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="machine-readable JSON output")
255
+ parser = argparse.ArgumentParser(
256
+ prog="comuvia",
257
+ description="Offline evidence and prediction records: validate, digest, verify, store, evaluate, reproduce.",
258
+ )
259
+ parser.add_argument("--json", action="store_true", help="machine-readable JSON output")
260
+ parser.add_argument("--version", action="version", version=f"comuvia {__version__}")
261
+ sub = parser.add_subparsers(dest="command", required=True)
262
+
263
+ p = sub.add_parser("validate", parents=[common], help="validate record files")
264
+ p.add_argument("files", nargs="+")
265
+ p.set_defaults(handler=cmd_validate)
266
+
267
+ p = sub.add_parser("digest", parents=[common], help="record digest of a record file, or content digest of raw bytes")
268
+ p.add_argument("file")
269
+ p.add_argument("--content", action="store_true", help="digest the file's exact bytes instead of a record")
270
+ p.set_defaults(handler=cmd_digest)
271
+
272
+ p = sub.add_parser("verify", parents=[common], help="verify a record against content bytes and a store")
273
+ p.add_argument("record")
274
+ p.add_argument("--content", help="retained bytes of the record's resource (snapshot, descriptor or assertion)")
275
+ p.add_argument("--expected-digest", help="the record digest the record should have")
276
+ p.add_argument("--store", help="store whose records resolve the record's references")
277
+ p.set_defaults(handler=cmd_verify)
278
+
279
+ store = sub.add_parser("store", parents=[common], help="single-writer record store").add_subparsers(dest="store_command", required=True)
280
+ p = store.add_parser("init", parents=[common], help="create an empty store")
281
+ p.add_argument("store")
282
+ p.set_defaults(handler=cmd_store_init)
283
+ p = store.add_parser("append", parents=[common], help="append record files (takes the writer lock)")
284
+ p.add_argument("store")
285
+ p.add_argument("files", nargs="+")
286
+ p.set_defaults(handler=cmd_store_append)
287
+ p = store.add_parser(
288
+ "verify", parents=[common],
289
+ help="check index, bodies and references by name (identity only; use 'reproduce' for evaluation results)",
290
+ )
291
+ p.add_argument("store")
292
+ p.set_defaults(handler=cmd_store_verify)
293
+ p = store.add_parser("show", parents=[common], help="list stored records")
294
+ p.add_argument("store")
295
+ p.add_argument("--latest", action="store_true", help="latest revision per record only")
296
+ p.add_argument("--record-type")
297
+ p.add_argument("--id")
298
+ p.add_argument("--bodies", action="store_true", help="include full record bodies (with --json)")
299
+ p.set_defaults(handler=cmd_store_show)
300
+ p = store.add_parser("repair", parents=[common], help="operator action: remove an incomplete final index line")
301
+ p.add_argument("store")
302
+ p.add_argument("--truncate-torn-tail", action="store_true", required=True)
303
+ p.add_argument("--confirm", action="store_true", required=True)
304
+ p.set_defaults(handler=cmd_store_repair)
305
+
306
+ p = sub.add_parser("evaluate", parents=[common], help="evaluate forecasts and narrative candidates")
307
+ source = p.add_mutually_exclusive_group(required=True)
308
+ source.add_argument("--store")
309
+ source.add_argument("--files", nargs="+")
310
+ p.add_argument("--append", action="store_true", help="append evaluation records to the store")
311
+ p.add_argument("--evaluated-at", help="explicit evaluation time for appended records (RFC 3339)")
312
+ p.add_argument("--recorded-by", help="agent recording the evaluations, e.g. evaluator:name")
313
+ p.add_argument("--recorded-at", help="recording time (RFC 3339); defaults to now")
314
+ p.set_defaults(handler=cmd_evaluate)
315
+
316
+ p = sub.add_parser("reproduce", parents=[common], help="re-derive stored evaluations from their pinned inputs")
317
+ p.add_argument("--store", required=True)
318
+ p.add_argument("ids", nargs="*", help="evaluation ids (default: every stored evaluation)")
319
+ p.set_defaults(handler=cmd_reproduce)
320
+ return parser
321
+
322
+
323
+ def main(argv: list[str] | None = None) -> int:
324
+ parser = build_parser()
325
+ args = parser.parse_args(argv)
326
+ out = _Output(args.json)
327
+ try:
328
+ return args.handler(args, out)
329
+ except _Usage as error:
330
+ parser.print_usage(sys.stderr)
331
+ print(f"comuvia: error: {error}", file=sys.stderr)
332
+ return USAGE
333
+ except RefusalError as error:
334
+ out.emit({"refused": True, "issues": _issues(error.issues)}, ["REFUSED"] + _issue_lines(error.issues))
335
+ return FAILED
336
+ except FileNotFoundError as error:
337
+ out.emit({"refused": True, "issues": [{"code": "file_not_found", "pointer": "", "message": str(error)}]},
338
+ [f"REFUSED: {error}"])
339
+ return FAILED
340
+
341
+
342
+ if __name__ == "__main__":
343
+ sys.exit(main())