sequentialist-core 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.
- sequentialist/core/__init__.py +84 -0
- sequentialist/core/audit.py +134 -0
- sequentialist/core/canonical.py +50 -0
- sequentialist/core/cli.py +95 -0
- sequentialist/core/engine.py +589 -0
- sequentialist/core/errors.py +40 -0
- sequentialist/core/privacy.py +30 -0
- sequentialist/core/py.typed +0 -0
- sequentialist/core/replay.py +40 -0
- sequentialist/core/report.py +141 -0
- sequentialist/core/schema.py +410 -0
- sequentialist/core/simulate.py +58 -0
- sequentialist/core/sinks.py +98 -0
- sequentialist/core/spec.py +120 -0
- sequentialist/core/storage/__init__.py +24 -0
- sequentialist/core/storage/_lock.py +91 -0
- sequentialist/core/storage/base.py +102 -0
- sequentialist/core/storage/files.py +174 -0
- sequentialist/core/storage/memory.py +64 -0
- sequentialist/core/storage/sqlite.py +150 -0
- sequentialist/core/version.py +4 -0
- sequentialist_core-0.1.0.dist-info/METADATA +129 -0
- sequentialist_core-0.1.0.dist-info/RECORD +28 -0
- sequentialist_core-0.1.0.dist-info/WHEEL +5 -0
- sequentialist_core-0.1.0.dist-info/entry_points.txt +6 -0
- sequentialist_core-0.1.0.dist-info/licenses/COMMERCIAL.md +89 -0
- sequentialist_core-0.1.0.dist-info/licenses/LICENSE +661 -0
- sequentialist_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 Novikov Laboratories LLC
|
|
3
|
+
# Commercial licenses for use outside the AGPL: see COMMERCIAL.md
|
|
4
|
+
"""sequentialist.core: what the sequentialist libraries share.
|
|
5
|
+
|
|
6
|
+
Statistics live in sequential-analysis. This package adds what turns its
|
|
7
|
+
procedures into applications:
|
|
8
|
+
|
|
9
|
+
spec declarative specs with schemas, normal forms and ids (`Spec`, `load_spec`)
|
|
10
|
+
engine tasks that take records idempotently, feed runs, decide and survive crashes (`Engine`, `Handler`)
|
|
11
|
+
storage memory, a directory of files, SQLite (`open_storage`)
|
|
12
|
+
audit the hash-chained log of decisions and actions (`verify_audit`)
|
|
13
|
+
report JSON and Markdown reports in which every finding names its guarantee
|
|
14
|
+
simulate error rates of a whole pipeline by simulation
|
|
15
|
+
replay recorded data through the live code, on the data's clock
|
|
16
|
+
sinks notifications: log, JSON lines, webhooks
|
|
17
|
+
privacy pseudonyms for identifiers of people
|
|
18
|
+
"""
|
|
19
|
+
from .audit import GUARANTEES, AuditEntry, AuditError, Evidence, verify_audit
|
|
20
|
+
from .engine import Command, Context, Engine, Handler, IngestResult, Record, Stream, Task, shard_of
|
|
21
|
+
from .errors import (
|
|
22
|
+
ConflictError,
|
|
23
|
+
InvalidRecord,
|
|
24
|
+
SequentialistError,
|
|
25
|
+
SpecError,
|
|
26
|
+
StorageError,
|
|
27
|
+
TaskError,
|
|
28
|
+
TaskLocked,
|
|
29
|
+
)
|
|
30
|
+
from .privacy import pseudonymize
|
|
31
|
+
from .replay import replay
|
|
32
|
+
from .report import Finding, Report, Section, Table
|
|
33
|
+
from .simulate import Proportion, proportion, run_simulations
|
|
34
|
+
from .sinks import JsonlSink, LogSink, WebhookSink
|
|
35
|
+
from .spec import Spec, json_schema, load_spec, parse_spec, spec_id
|
|
36
|
+
from .storage import FileStorage, MemoryStorage, SQLiteStorage, Storage, open_storage
|
|
37
|
+
from .version import __version__
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"GUARANTEES",
|
|
41
|
+
"AuditEntry",
|
|
42
|
+
"AuditError",
|
|
43
|
+
"Command",
|
|
44
|
+
"ConflictError",
|
|
45
|
+
"Context",
|
|
46
|
+
"Engine",
|
|
47
|
+
"Evidence",
|
|
48
|
+
"FileStorage",
|
|
49
|
+
"Finding",
|
|
50
|
+
"Handler",
|
|
51
|
+
"IngestResult",
|
|
52
|
+
"InvalidRecord",
|
|
53
|
+
"JsonlSink",
|
|
54
|
+
"LogSink",
|
|
55
|
+
"MemoryStorage",
|
|
56
|
+
"Proportion",
|
|
57
|
+
"Record",
|
|
58
|
+
"Report",
|
|
59
|
+
"SQLiteStorage",
|
|
60
|
+
"Section",
|
|
61
|
+
"SequentialistError",
|
|
62
|
+
"Spec",
|
|
63
|
+
"SpecError",
|
|
64
|
+
"Storage",
|
|
65
|
+
"StorageError",
|
|
66
|
+
"Stream",
|
|
67
|
+
"Table",
|
|
68
|
+
"Task",
|
|
69
|
+
"TaskError",
|
|
70
|
+
"TaskLocked",
|
|
71
|
+
"WebhookSink",
|
|
72
|
+
"__version__",
|
|
73
|
+
"json_schema",
|
|
74
|
+
"load_spec",
|
|
75
|
+
"open_storage",
|
|
76
|
+
"parse_spec",
|
|
77
|
+
"proportion",
|
|
78
|
+
"pseudonymize",
|
|
79
|
+
"replay",
|
|
80
|
+
"run_simulations",
|
|
81
|
+
"shard_of",
|
|
82
|
+
"spec_id",
|
|
83
|
+
"verify_audit",
|
|
84
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 Novikov Laboratories LLC
|
|
3
|
+
# Commercial licenses for use outside the AGPL: see COMMERCIAL.md
|
|
4
|
+
"""The audit log: every decision and action of a task, chained by hashes.
|
|
5
|
+
|
|
6
|
+
An entry records what was decided (``decision``), on what grounds
|
|
7
|
+
(``basis``: the rule and its numbers), from which evidence (``evidence``:
|
|
8
|
+
for every stream consulted, the procedure, its guarantee, the step and
|
|
9
|
+
the history digest of sequential-analysis), and, for
|
|
10
|
+
actions of people, who did it and why (``actor``, ``reason``).
|
|
11
|
+
|
|
12
|
+
Each entry holds the hash of the one before it::
|
|
13
|
+
|
|
14
|
+
hash_n = SHA-256(hash_{n-1} || canonical JSON of entry n without its hash)
|
|
15
|
+
|
|
16
|
+
so an entry changed, removed or inserted afterwards breaks the chain at
|
|
17
|
+
that point, which `verify_audit` reports. The chain shows tampering; it
|
|
18
|
+
does not prevent it, and it does not by itself make a system compliant
|
|
19
|
+
with any regulation.
|
|
20
|
+
|
|
21
|
+
The history digest in the evidence identifies the exact sequence of
|
|
22
|
+
inputs a procedure had seen; given the archived observations, anyone can
|
|
23
|
+
recompute it with sequential-analysis (in Python or TypeScript) and
|
|
24
|
+
check that the decision followed from the data.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, Sequence
|
|
31
|
+
|
|
32
|
+
from .canonical import canonical_json
|
|
33
|
+
from .errors import SequentialistError
|
|
34
|
+
|
|
35
|
+
FORMAT = "sequentialist.audit/1"
|
|
36
|
+
GENESIS = hashlib.sha256(FORMAT.encode("utf-8")).hexdigest()
|
|
37
|
+
#: What a conclusion can promise, as sequential-analysis states it on every procedure.
|
|
38
|
+
GUARANTEES = ("exact", "asymptotic", "model", "approximate", "none")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuditError(SequentialistError):
|
|
42
|
+
"""The audit log is broken: the message names the first entry that does not verify."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Evidence:
|
|
47
|
+
"""One stream's state behind a decision."""
|
|
48
|
+
|
|
49
|
+
stream: str
|
|
50
|
+
run_id: str
|
|
51
|
+
t: int
|
|
52
|
+
procedure: dict[str, Any] # the procedure's identity(): name, version, config
|
|
53
|
+
guarantee: str
|
|
54
|
+
history_digest: str
|
|
55
|
+
output: Any = None # the stream's last output, encoded
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def of(cls, stream: str, run: Any, output: Any = None) -> Evidence:
|
|
59
|
+
"""Evidence from a sequential_analysis Run (between transitions)."""
|
|
60
|
+
return cls(stream, run.run_id, run.t, run.procedure.identity(), str(run.procedure.guarantee),
|
|
61
|
+
run.history_digest, output)
|
|
62
|
+
|
|
63
|
+
def to_json(self) -> dict[str, Any]:
|
|
64
|
+
return {"stream": self.stream, "run_id": self.run_id, "t": self.t, "procedure": self.procedure,
|
|
65
|
+
"guarantee": self.guarantee, "history_digest": self.history_digest, "output": self.output}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class AuditEntry:
|
|
70
|
+
n: int # position in the task's log, from 0
|
|
71
|
+
task_id: str
|
|
72
|
+
spec_id: str
|
|
73
|
+
kind: str # "created", "decision", "action", "segment", ...
|
|
74
|
+
decision: str | None
|
|
75
|
+
at: float # seconds since the epoch, by the engine's clock
|
|
76
|
+
basis: dict[str, Any] = field(default_factory=dict)
|
|
77
|
+
evidence: tuple[dict[str, Any], ...] = ()
|
|
78
|
+
actor: str | None = None
|
|
79
|
+
reason: str | None = None
|
|
80
|
+
prev: str = GENESIS
|
|
81
|
+
hash: str = ""
|
|
82
|
+
|
|
83
|
+
def body(self) -> dict[str, Any]:
|
|
84
|
+
return {"format": FORMAT, "n": self.n, "task_id": self.task_id, "spec_id": self.spec_id, "kind": self.kind,
|
|
85
|
+
"decision": self.decision, "at": self.at, "basis": self.basis, "evidence": list(self.evidence),
|
|
86
|
+
"actor": self.actor, "reason": self.reason, "prev": self.prev}
|
|
87
|
+
|
|
88
|
+
def to_json(self) -> dict[str, Any]:
|
|
89
|
+
return {**self.body(), "hash": self.hash}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_json(cls, data: dict[str, Any]) -> AuditEntry:
|
|
93
|
+
return cls(n=data["n"], task_id=data["task_id"], spec_id=data["spec_id"], kind=data["kind"],
|
|
94
|
+
decision=data["decision"], at=data["at"], basis=data["basis"], evidence=tuple(data["evidence"]),
|
|
95
|
+
actor=data["actor"], reason=data["reason"], prev=data["prev"], hash=data["hash"])
|
|
96
|
+
|
|
97
|
+
def sealed(self) -> AuditEntry:
|
|
98
|
+
"""The entry with its hash computed."""
|
|
99
|
+
body = self.body()
|
|
100
|
+
digest = hashlib.sha256((self.prev + canonical_json(body)).encode("utf-8")).hexdigest()
|
|
101
|
+
return AuditEntry(**{**self.__dict__, "hash": digest})
|
|
102
|
+
|
|
103
|
+
def same_event(self, other: AuditEntry) -> bool:
|
|
104
|
+
"""The same decision or action, whenever it was written (`at` and the hashes aside)."""
|
|
105
|
+
mine, theirs = self.body(), other.body()
|
|
106
|
+
for name in ("at", "prev"):
|
|
107
|
+
mine.pop(name)
|
|
108
|
+
theirs.pop(name)
|
|
109
|
+
return canonical_json(mine) == canonical_json(theirs)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verify_audit(entries: Sequence[dict[str, Any]], task_id: str | None = None) -> int:
|
|
113
|
+
"""Check the chain of a task's audit log; return the number of entries, AuditError at the first bad one."""
|
|
114
|
+
prev = GENESIS
|
|
115
|
+
for i, data in enumerate(entries):
|
|
116
|
+
try:
|
|
117
|
+
entry = AuditEntry.from_json(data)
|
|
118
|
+
except (KeyError, TypeError) as error:
|
|
119
|
+
raise AuditError(f"entry {i}: malformed ({error!r})") from None
|
|
120
|
+
if data.get("format") != FORMAT:
|
|
121
|
+
raise AuditError(f"entry {i}: format {data.get('format')!r}, expected {FORMAT!r}")
|
|
122
|
+
if entry.n != i:
|
|
123
|
+
raise AuditError(f"entry {i}: numbered {entry.n}; an entry is missing or out of place")
|
|
124
|
+
if task_id is not None and entry.task_id != task_id:
|
|
125
|
+
raise AuditError(f"entry {i}: belongs to task {entry.task_id!r}")
|
|
126
|
+
if entry.prev != prev:
|
|
127
|
+
raise AuditError(f"entry {i}: does not follow the entry before it")
|
|
128
|
+
if entry.sealed().hash != entry.hash:
|
|
129
|
+
raise AuditError(f"entry {i}: its content does not match its hash; it was changed")
|
|
130
|
+
for evidence in entry.evidence:
|
|
131
|
+
if evidence.get("guarantee") not in GUARANTEES:
|
|
132
|
+
raise AuditError(f"entry {i}: evidence without a known guarantee")
|
|
133
|
+
prev = entry.hash
|
|
134
|
+
return len(entries)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 Novikov Laboratories LLC
|
|
3
|
+
# Commercial licenses for use outside the AGPL: see COMMERCIAL.md
|
|
4
|
+
"""JSON as the stored state needs it: canonical bytes for hashes, exact numbers when read back.
|
|
5
|
+
|
|
6
|
+
These are the functions sequential-analysis uses for its checkpoints and
|
|
7
|
+
history digests, so that a spec id or an audit hash computed here is
|
|
8
|
+
computed the same way as the library's own digests.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import math
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sequential_analysis.canonical import canonical_json, loads
|
|
17
|
+
|
|
18
|
+
__all__ = ["canonical_json", "digest", "is_plain", "loads"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def digest(value: Any) -> str:
|
|
22
|
+
"""SHA-256 of the canonical JSON of `value`, as hex."""
|
|
23
|
+
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(value: Any) -> bool:
|
|
27
|
+
if type(value) is not str:
|
|
28
|
+
return False
|
|
29
|
+
try:
|
|
30
|
+
value.encode("utf-8")
|
|
31
|
+
except UnicodeEncodeError:
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_plain(value: Any) -> bool:
|
|
37
|
+
"""True for JSON that survives storage exactly: finite floats, integers within 2**53, UTF-8 text."""
|
|
38
|
+
if value is None or type(value) is bool:
|
|
39
|
+
return True
|
|
40
|
+
if type(value) is int:
|
|
41
|
+
return abs(value) <= 2**53
|
|
42
|
+
if type(value) is float:
|
|
43
|
+
return math.isfinite(value)
|
|
44
|
+
if type(value) is str:
|
|
45
|
+
return _text(value)
|
|
46
|
+
if type(value) is list:
|
|
47
|
+
return all(is_plain(v) for v in value)
|
|
48
|
+
if type(value) is dict:
|
|
49
|
+
return all(_text(k) and is_plain(v) for k, v in value.items())
|
|
50
|
+
return False
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 Novikov Laboratories LLC
|
|
3
|
+
# Commercial licenses for use outside the AGPL: see COMMERCIAL.md
|
|
4
|
+
"""The `sequentialist` command. Every installed library adds its subcommands.
|
|
5
|
+
|
|
6
|
+
sequentialist tasks STORAGE list the tasks in a storage
|
|
7
|
+
sequentialist audit STORAGE TASK verify a task's audit log and print it
|
|
8
|
+
sequentialist <library> ... e.g. `sequentialist ab --help`
|
|
9
|
+
|
|
10
|
+
STORAGE is ``files:<directory>`` or ``sqlite:<file>``.
|
|
11
|
+
|
|
12
|
+
A library registers a subcommand with an entry point in the group
|
|
13
|
+
``sequentialist.commands``: a function that receives the subparsers object
|
|
14
|
+
and adds its parser, with ``set_defaults(handler=...)``.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
from .audit import AuditError, verify_audit
|
|
25
|
+
from .errors import SequentialistError
|
|
26
|
+
from .storage import open_storage
|
|
27
|
+
from .version import __version__
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _entry_points() -> list[Any]:
|
|
31
|
+
from importlib.metadata import entry_points
|
|
32
|
+
|
|
33
|
+
found = entry_points()
|
|
34
|
+
if hasattr(found, "select"): # Python 3.10 and later
|
|
35
|
+
return list(found.select(group="sequentialist.commands"))
|
|
36
|
+
return list(found.get("sequentialist.commands", []))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tasks_command(commands: Any) -> None:
|
|
40
|
+
parser = commands.add_parser("tasks", help="list the tasks in a storage")
|
|
41
|
+
parser.add_argument("storage")
|
|
42
|
+
|
|
43
|
+
def handler(args: argparse.Namespace) -> int:
|
|
44
|
+
with open_storage(args.storage) as storage:
|
|
45
|
+
for task_id in storage.tasks():
|
|
46
|
+
print(task_id)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
parser.set_defaults(handler=handler)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def audit_command(commands: Any) -> None:
|
|
53
|
+
parser = commands.add_parser("audit", help="verify a task's audit log and print it")
|
|
54
|
+
parser.add_argument("storage")
|
|
55
|
+
parser.add_argument("task")
|
|
56
|
+
parser.add_argument("--json", action="store_true", help="print the entries as JSON lines")
|
|
57
|
+
|
|
58
|
+
def handler(args: argparse.Namespace) -> int:
|
|
59
|
+
with open_storage(args.storage) as storage:
|
|
60
|
+
entries = storage.audit_read(args.task)
|
|
61
|
+
try:
|
|
62
|
+
count = verify_audit(entries, args.task)
|
|
63
|
+
except AuditError as error:
|
|
64
|
+
print(f"audit log of {args.task}: BROKEN: {error}", file=sys.stderr)
|
|
65
|
+
return 1
|
|
66
|
+
for e in entries:
|
|
67
|
+
if args.json:
|
|
68
|
+
print(json.dumps(e, sort_keys=True, ensure_ascii=False))
|
|
69
|
+
else:
|
|
70
|
+
who = f" by {e['actor']} ({e['reason']})" if e.get("actor") else ""
|
|
71
|
+
print(f"{e['n']:>5} {e['kind']:<9} {e['decision'] or '':<12}{who} {json.dumps(e['basis'])}")
|
|
72
|
+
print(f"audit log of {args.task}: {count} entries, chain verified", file=sys.stderr)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
parser.set_defaults(handler=handler)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv: list[str] | None = None) -> int:
|
|
79
|
+
parser = argparse.ArgumentParser(prog="sequentialist", description="The sequentialist libraries.")
|
|
80
|
+
parser.add_argument("--version", action="version", version=f"sequentialist-core {__version__}")
|
|
81
|
+
parser.add_argument("--log-level", default="WARNING")
|
|
82
|
+
commands = parser.add_subparsers(dest="command")
|
|
83
|
+
for point in sorted(_entry_points(), key=lambda p: p.name):
|
|
84
|
+
register: Callable[[Any], None] = point.load()
|
|
85
|
+
register(commands)
|
|
86
|
+
args = parser.parse_args(argv)
|
|
87
|
+
logging.basicConfig(level=args.log_level.upper(), format="%(levelname)s %(name)s: %(message)s")
|
|
88
|
+
if not getattr(args, "handler", None):
|
|
89
|
+
parser.print_help()
|
|
90
|
+
return 2
|
|
91
|
+
try:
|
|
92
|
+
return int(args.handler(args))
|
|
93
|
+
except (SequentialistError, ValueError, OSError) as error:
|
|
94
|
+
print(f"sequentialist: {error}", file=sys.stderr)
|
|
95
|
+
return 2
|