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.
@@ -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