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,40 @@
|
|
|
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
|
+
"""Replays: recorded data through the same engine and handler as live, in memory, on the data's own clock.
|
|
5
|
+
|
|
6
|
+
A replay re-analyses history (a finished experiment, last year's sensor
|
|
7
|
+
data, a backtest) with exactly the code that runs live. The engine's
|
|
8
|
+
clock is the event time of the data, so two replays of the same records
|
|
9
|
+
give the same audit log, hashes included.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Iterable, Sequence, Union
|
|
14
|
+
|
|
15
|
+
from .engine import Engine, Handler, Record, Sink, Task
|
|
16
|
+
from .storage.memory import MemoryStorage
|
|
17
|
+
|
|
18
|
+
Input = Union[Record, float] # a record, or a time to advance the watermark to
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def replay(kind: str, spec: dict[str, Any], handler: Handler, inputs: Iterable[Input], *, task_id: str = "replay",
|
|
22
|
+
batch: int = 1000, sinks: Sequence[Sink] = ()) -> Task:
|
|
23
|
+
"""Run `inputs` through a new task in memory; the task is returned open (close it when done)."""
|
|
24
|
+
engine = Engine(MemoryStorage(), sinks=sinks, clock="event", checkpoint_every=10**9)
|
|
25
|
+
task = engine.create(task_id, kind, spec, handler)
|
|
26
|
+
pending: list[Record] = []
|
|
27
|
+
for item in inputs:
|
|
28
|
+
if isinstance(item, Record):
|
|
29
|
+
pending.append(item)
|
|
30
|
+
if len(pending) >= batch:
|
|
31
|
+
task.ingest(pending)
|
|
32
|
+
pending = []
|
|
33
|
+
else:
|
|
34
|
+
if pending:
|
|
35
|
+
task.ingest(pending)
|
|
36
|
+
pending = []
|
|
37
|
+
task.advance(item)
|
|
38
|
+
if pending:
|
|
39
|
+
task.ingest(pending)
|
|
40
|
+
return task
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
"""Reports in JSON and Markdown in which every conclusion names its guarantee.
|
|
5
|
+
|
|
6
|
+
A `Finding` cannot be made without a guarantee, one of those
|
|
7
|
+
sequential-analysis states on its procedures (exact, asymptotic, model,
|
|
8
|
+
approximate, none); the Markdown rendering prints it next to the
|
|
9
|
+
conclusion and explains the terms at the end.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Sequence
|
|
16
|
+
|
|
17
|
+
from sequential_analysis.core import GUARANTEES as _MEANINGS
|
|
18
|
+
|
|
19
|
+
from .audit import GUARANTEES
|
|
20
|
+
from .canonical import is_plain
|
|
21
|
+
from .schema import format_timestamp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Finding:
|
|
26
|
+
statement: str
|
|
27
|
+
guarantee: str
|
|
28
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
stream: str | None = None
|
|
30
|
+
procedure: dict[str, Any] | None = None # identity() of the procedure behind it
|
|
31
|
+
history_digest: str | None = None
|
|
32
|
+
notes: Sequence[str] = ()
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if self.guarantee not in GUARANTEES:
|
|
36
|
+
raise ValueError(f"a finding needs a guarantee, one of {GUARANTEES}; got {self.guarantee!r}")
|
|
37
|
+
if not is_plain(self.values):
|
|
38
|
+
raise ValueError("the values of a finding must be plain JSON")
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> dict[str, Any]:
|
|
41
|
+
return {"statement": self.statement, "guarantee": self.guarantee, "values": self.values,
|
|
42
|
+
"stream": self.stream, "procedure": self.procedure, "history_digest": self.history_digest,
|
|
43
|
+
"notes": list(self.notes)}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Table:
|
|
48
|
+
columns: Sequence[str]
|
|
49
|
+
rows: Sequence[Sequence[Any]]
|
|
50
|
+
caption: str = ""
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> dict[str, Any]:
|
|
53
|
+
return {"caption": self.caption, "columns": list(self.columns), "rows": [list(r) for r in self.rows]}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Section:
|
|
58
|
+
title: str
|
|
59
|
+
text: str = ""
|
|
60
|
+
findings: list[Finding] = field(default_factory=list)
|
|
61
|
+
tables: list[Table] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
def to_json(self) -> dict[str, Any]:
|
|
64
|
+
return {"title": self.title, "text": self.text, "findings": [f.to_json() for f in self.findings],
|
|
65
|
+
"tables": [t.to_json() for t in self.tables]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fmt(value: Any) -> str:
|
|
69
|
+
"""A value as a report prints it: numbers to four significant digits, None as a dash."""
|
|
70
|
+
if value is None:
|
|
71
|
+
return "—"
|
|
72
|
+
if type(value) is bool:
|
|
73
|
+
return "yes" if value else "no"
|
|
74
|
+
if type(value) is float:
|
|
75
|
+
if not math.isfinite(value):
|
|
76
|
+
return str(value)
|
|
77
|
+
if value == 0:
|
|
78
|
+
return "0"
|
|
79
|
+
if abs(value) >= 1e5 or abs(value) < 1e-3:
|
|
80
|
+
return f"{value:.3e}"
|
|
81
|
+
return f"{value:.4g}"
|
|
82
|
+
if isinstance(value, (list, tuple)):
|
|
83
|
+
return "[" + ", ".join(fmt(v) for v in value) + "]"
|
|
84
|
+
return str(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Report:
|
|
89
|
+
title: str
|
|
90
|
+
task_id: str
|
|
91
|
+
spec_id: str
|
|
92
|
+
generated_at: float
|
|
93
|
+
summary: str = ""
|
|
94
|
+
sections: list[Section] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
def findings(self) -> list[Finding]:
|
|
97
|
+
return [f for s in self.sections for f in s.findings]
|
|
98
|
+
|
|
99
|
+
def to_json(self) -> dict[str, Any]:
|
|
100
|
+
return {"format": "sequentialist.report/1", "title": self.title, "task_id": self.task_id,
|
|
101
|
+
"spec_id": self.spec_id, "generated_at": self.generated_at, "summary": self.summary,
|
|
102
|
+
"sections": [s.to_json() for s in self.sections]}
|
|
103
|
+
|
|
104
|
+
def to_markdown(self) -> str:
|
|
105
|
+
out = [f"# {self.title}", "",
|
|
106
|
+
f"Task `{self.task_id}`, spec `{self.spec_id[:16]}…`, generated {format_timestamp(self.generated_at)}.",
|
|
107
|
+
""]
|
|
108
|
+
if self.summary:
|
|
109
|
+
out += [self.summary, ""]
|
|
110
|
+
used = set()
|
|
111
|
+
for section in self.sections:
|
|
112
|
+
out += [f"## {section.title}", ""]
|
|
113
|
+
if section.text:
|
|
114
|
+
out += [section.text, ""]
|
|
115
|
+
for finding in section.findings:
|
|
116
|
+
used.add(finding.guarantee)
|
|
117
|
+
out.append(f"- {finding.statement} *Guarantee: {finding.guarantee}.*")
|
|
118
|
+
if finding.values:
|
|
119
|
+
out.append(" " + "; ".join(f"{k} = {fmt(v)}" for k, v in finding.values.items()))
|
|
120
|
+
for note in finding.notes:
|
|
121
|
+
out.append(f" {note}")
|
|
122
|
+
if section.findings:
|
|
123
|
+
out.append("")
|
|
124
|
+
for table in section.tables:
|
|
125
|
+
if table.caption:
|
|
126
|
+
out += [table.caption, ""]
|
|
127
|
+
out.append("| " + " | ".join(table.columns) + " |")
|
|
128
|
+
out.append("|" + "---|" * len(table.columns))
|
|
129
|
+
for row in table.rows:
|
|
130
|
+
out.append("| " + " | ".join(fmt(v) for v in row) + " |")
|
|
131
|
+
out.append("")
|
|
132
|
+
if "guarantee" in table.columns:
|
|
133
|
+
index = list(table.columns).index("guarantee")
|
|
134
|
+
used.update(str(r[index]) for r in table.rows if r[index] in GUARANTEES)
|
|
135
|
+
if used:
|
|
136
|
+
out += ["## Guarantees", ""]
|
|
137
|
+
for name in GUARANTEES:
|
|
138
|
+
if name in used:
|
|
139
|
+
out.append(f"- **{name}**: {_MEANINGS[name]}.")
|
|
140
|
+
out.append("")
|
|
141
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,410 @@
|
|
|
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
|
+
"""Declarative schemas for specs: validation with every error at once, normalisation, JSON Schema.
|
|
5
|
+
|
|
6
|
+
A schema checks a parsed JSON (or YAML) value and returns its *normal
|
|
7
|
+
form*: defaults filled in, durations in seconds, timestamps in seconds
|
|
8
|
+
since the epoch. The normal form is plain JSON, checks again to itself,
|
|
9
|
+
and is what a spec id is computed from: two spellings of the same spec
|
|
10
|
+
(``"7d"`` and ``604800``) have the same id.
|
|
11
|
+
|
|
12
|
+
Errors are collected, not raised one by one: `check` appends
|
|
13
|
+
``(JSON pointer, message)`` to a list and returns `INVALID` for a value it
|
|
14
|
+
cannot use, so a user sees every problem of a spec in one go.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime as _dt
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, List, Mapping, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
Errors = List[Tuple[str, str]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Invalid:
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return "INVALID"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
#: Returned by `Schema.check` for a value that could not be used.
|
|
32
|
+
INVALID: Any = _Invalid()
|
|
33
|
+
#: Default of a field that has none.
|
|
34
|
+
MISSING: Any = object()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pointer(path: str, key: Any) -> str:
|
|
38
|
+
"""The JSON pointer of `key` (a member name or an index) below `path`."""
|
|
39
|
+
return f"{path}/{str(key).replace('~', '~0').replace('/', '~1')}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _type_name(value: Any) -> str:
|
|
43
|
+
if value is None:
|
|
44
|
+
return "null"
|
|
45
|
+
if type(value) is bool:
|
|
46
|
+
return "a boolean"
|
|
47
|
+
if type(value) in (int, float):
|
|
48
|
+
return "a number"
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return "a string"
|
|
51
|
+
if isinstance(value, list):
|
|
52
|
+
return "a list"
|
|
53
|
+
if isinstance(value, dict):
|
|
54
|
+
return "an object"
|
|
55
|
+
return type(value).__name__
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Schema:
|
|
59
|
+
"""Base class: `check` validates and normalises, `json_schema` describes."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, doc: str = ""):
|
|
62
|
+
self.doc = doc
|
|
63
|
+
|
|
64
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any: # pragma: no cover - abstract
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
def _describe(self) -> dict[str, Any]: # pragma: no cover - abstract
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
def json_schema(self) -> dict[str, Any]:
|
|
71
|
+
out = self._describe()
|
|
72
|
+
if self.doc:
|
|
73
|
+
out["description"] = self.doc
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class String(Schema):
|
|
78
|
+
def __init__(self, *, pattern: str | None = None, enum: Sequence[str] | None = None, min_length: int = 0,
|
|
79
|
+
max_length: int | None = None, doc: str = ""):
|
|
80
|
+
super().__init__(doc)
|
|
81
|
+
self.pattern = pattern
|
|
82
|
+
self._regex = re.compile(pattern) if pattern is not None else None
|
|
83
|
+
self.enum = tuple(enum) if enum is not None else None
|
|
84
|
+
self.min_length, self.max_length = min_length, max_length
|
|
85
|
+
|
|
86
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
87
|
+
if not isinstance(value, str):
|
|
88
|
+
errors.append((path, f"expected a string, got {_type_name(value)}"))
|
|
89
|
+
return INVALID
|
|
90
|
+
try:
|
|
91
|
+
value.encode("utf-8")
|
|
92
|
+
except UnicodeEncodeError:
|
|
93
|
+
errors.append((path, "not valid Unicode text"))
|
|
94
|
+
return INVALID
|
|
95
|
+
if self.enum is not None and value not in self.enum:
|
|
96
|
+
errors.append((path, f"{value!r} is not one of {list(self.enum)}"))
|
|
97
|
+
return INVALID
|
|
98
|
+
if len(value) < self.min_length:
|
|
99
|
+
errors.append((path, "must not be empty" if self.min_length == 1 else
|
|
100
|
+
f"must have at least {self.min_length} characters"))
|
|
101
|
+
return INVALID
|
|
102
|
+
if self.max_length is not None and len(value) > self.max_length:
|
|
103
|
+
errors.append((path, f"must have at most {self.max_length} characters"))
|
|
104
|
+
return INVALID
|
|
105
|
+
if self._regex is not None and not self._regex.fullmatch(value):
|
|
106
|
+
errors.append((path, f"{value!r} does not match {self.pattern}"))
|
|
107
|
+
return INVALID
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
def _describe(self) -> dict[str, Any]:
|
|
111
|
+
out: dict[str, Any] = {"type": "string"}
|
|
112
|
+
if self.enum is not None:
|
|
113
|
+
out["enum"] = list(self.enum)
|
|
114
|
+
if self.pattern is not None:
|
|
115
|
+
out["pattern"] = f"^(?:{self.pattern})$"
|
|
116
|
+
if self.min_length:
|
|
117
|
+
out["minLength"] = self.min_length
|
|
118
|
+
if self.max_length is not None:
|
|
119
|
+
out["maxLength"] = self.max_length
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Number(Schema):
|
|
124
|
+
def __init__(self, *, minimum: float | None = None, maximum: float | None = None,
|
|
125
|
+
exclusive_minimum: float | None = None, exclusive_maximum: float | None = None,
|
|
126
|
+
integer: bool = False, doc: str = ""):
|
|
127
|
+
super().__init__(doc)
|
|
128
|
+
self.minimum, self.maximum = minimum, maximum
|
|
129
|
+
self.exclusive_minimum, self.exclusive_maximum = exclusive_minimum, exclusive_maximum
|
|
130
|
+
self.integer = integer
|
|
131
|
+
|
|
132
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
133
|
+
if type(value) not in (int, float):
|
|
134
|
+
errors.append((path, f"expected {'an integer' if self.integer else 'a number'}, got {_type_name(value)}"))
|
|
135
|
+
return INVALID
|
|
136
|
+
if not math.isfinite(value):
|
|
137
|
+
errors.append((path, "must be finite"))
|
|
138
|
+
return INVALID
|
|
139
|
+
if self.integer:
|
|
140
|
+
if value != int(value) or abs(value) > 2**53:
|
|
141
|
+
errors.append((path, f"expected an integer within 2**53, got {value!r}"))
|
|
142
|
+
return INVALID
|
|
143
|
+
value = int(value)
|
|
144
|
+
elif type(value) is int and abs(value) > 2**53:
|
|
145
|
+
errors.append((path, "integers beyond 2**53 are not exact in JSON"))
|
|
146
|
+
return INVALID
|
|
147
|
+
for bound, ok, text in (
|
|
148
|
+
(self.minimum, lambda b: value >= b, "at least"),
|
|
149
|
+
(self.maximum, lambda b: value <= b, "at most"),
|
|
150
|
+
(self.exclusive_minimum, lambda b: value > b, "greater than"),
|
|
151
|
+
(self.exclusive_maximum, lambda b: value < b, "less than"),
|
|
152
|
+
):
|
|
153
|
+
if bound is not None and not ok(bound):
|
|
154
|
+
errors.append((path, f"must be {text} {bound}, got {value!r}"))
|
|
155
|
+
return INVALID
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
def _describe(self) -> dict[str, Any]:
|
|
159
|
+
out: dict[str, Any] = {"type": "integer" if self.integer else "number"}
|
|
160
|
+
for key, bound in (("minimum", self.minimum), ("maximum", self.maximum),
|
|
161
|
+
("exclusiveMinimum", self.exclusive_minimum),
|
|
162
|
+
("exclusiveMaximum", self.exclusive_maximum)):
|
|
163
|
+
if bound is not None:
|
|
164
|
+
out[key] = bound
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Boolean(Schema):
|
|
169
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
170
|
+
if type(value) is not bool:
|
|
171
|
+
errors.append((path, f"expected true or false, got {_type_name(value)}"))
|
|
172
|
+
return INVALID
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
def _describe(self) -> dict[str, Any]:
|
|
176
|
+
return {"type": "boolean"}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Nullable(Schema):
|
|
180
|
+
def __init__(self, inner: Schema, doc: str = ""):
|
|
181
|
+
super().__init__(doc or inner.doc)
|
|
182
|
+
self.inner = inner
|
|
183
|
+
|
|
184
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
185
|
+
return None if value is None else self.inner.check(value, path, errors)
|
|
186
|
+
|
|
187
|
+
def _describe(self) -> dict[str, Any]:
|
|
188
|
+
inner = self.inner._describe()
|
|
189
|
+
return {"anyOf": [inner, {"type": "null"}]}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Array(Schema):
|
|
193
|
+
def __init__(self, items: Schema, *, min_items: int = 0, max_items: int | None = None, doc: str = ""):
|
|
194
|
+
super().__init__(doc)
|
|
195
|
+
self.items, self.min_items, self.max_items = items, min_items, max_items
|
|
196
|
+
|
|
197
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
198
|
+
if not isinstance(value, (list, tuple)):
|
|
199
|
+
errors.append((path, f"expected a list, got {_type_name(value)}"))
|
|
200
|
+
return INVALID
|
|
201
|
+
if len(value) < self.min_items:
|
|
202
|
+
errors.append((path, f"needs at least {self.min_items} item{'s' if self.min_items != 1 else ''}"))
|
|
203
|
+
if self.max_items is not None and len(value) > self.max_items:
|
|
204
|
+
errors.append((path, f"has at most {self.max_items} items"))
|
|
205
|
+
out = [self.items.check(v, pointer(path, i), errors) for i, v in enumerate(value)]
|
|
206
|
+
return INVALID if any(v is INVALID for v in out) else out
|
|
207
|
+
|
|
208
|
+
def _describe(self) -> dict[str, Any]:
|
|
209
|
+
out: dict[str, Any] = {"type": "array", "items": self.items.json_schema()}
|
|
210
|
+
if self.min_items:
|
|
211
|
+
out["minItems"] = self.min_items
|
|
212
|
+
if self.max_items is not None:
|
|
213
|
+
out["maxItems"] = self.max_items
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class Map(Schema):
|
|
218
|
+
"""An object with arbitrary keys (matching `key_pattern`) and values of one schema."""
|
|
219
|
+
|
|
220
|
+
def __init__(self, values: Schema, *, key_pattern: str | None = None, doc: str = ""):
|
|
221
|
+
super().__init__(doc)
|
|
222
|
+
self.values, self.key_pattern = values, key_pattern
|
|
223
|
+
self._regex = re.compile(key_pattern) if key_pattern is not None else None
|
|
224
|
+
|
|
225
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
226
|
+
if not isinstance(value, dict):
|
|
227
|
+
errors.append((path, f"expected an object, got {_type_name(value)}"))
|
|
228
|
+
return INVALID
|
|
229
|
+
out = {}
|
|
230
|
+
for key, item in value.items():
|
|
231
|
+
where = pointer(path, key)
|
|
232
|
+
if not isinstance(key, str) or (self._regex is not None and not self._regex.fullmatch(key)):
|
|
233
|
+
errors.append((where, f"key {key!r} does not match {self.key_pattern}"))
|
|
234
|
+
out[str(key)] = INVALID
|
|
235
|
+
continue
|
|
236
|
+
out[key] = self.values.check(item, where, errors)
|
|
237
|
+
return INVALID if any(v is INVALID for v in out.values()) else out
|
|
238
|
+
|
|
239
|
+
def _describe(self) -> dict[str, Any]:
|
|
240
|
+
out: dict[str, Any] = {"type": "object", "additionalProperties": self.values.json_schema()}
|
|
241
|
+
if self.key_pattern is not None:
|
|
242
|
+
out["propertyNames"] = {"pattern": f"^(?:{self.key_pattern})$"}
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class Field:
|
|
247
|
+
"""A member of an Object: its schema, whether it is required, its default."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, schema: Schema, *, default: Any = MISSING, doc: str = ""):
|
|
250
|
+
self.schema, self.default = schema, default
|
|
251
|
+
if doc:
|
|
252
|
+
schema.doc = doc
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def required(self) -> bool:
|
|
256
|
+
return self.default is MISSING
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Object(Schema):
|
|
260
|
+
def __init__(self, fields: Mapping[str, Field], *, doc: str = ""):
|
|
261
|
+
super().__init__(doc)
|
|
262
|
+
self.fields = dict(fields)
|
|
263
|
+
|
|
264
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
265
|
+
if not isinstance(value, dict):
|
|
266
|
+
errors.append((path, f"expected an object, got {_type_name(value)}"))
|
|
267
|
+
return INVALID
|
|
268
|
+
out: dict[str, Any] = {}
|
|
269
|
+
unknown = [key for key in value if key not in self.fields]
|
|
270
|
+
for key in unknown:
|
|
271
|
+
errors.append((pointer(path, key), f"unknown member; known: {', '.join(sorted(self.fields))}"))
|
|
272
|
+
for name, member in self.fields.items():
|
|
273
|
+
where = pointer(path, name)
|
|
274
|
+
if name in value:
|
|
275
|
+
out[name] = member.schema.check(value[name], where, errors)
|
|
276
|
+
elif member.required:
|
|
277
|
+
errors.append((where, "required"))
|
|
278
|
+
out[name] = INVALID
|
|
279
|
+
else:
|
|
280
|
+
default = member.default() if callable(member.default) else member.default
|
|
281
|
+
out[name] = member.schema.check(default, where, errors) if default is not None else None
|
|
282
|
+
return INVALID if unknown or any(v is INVALID for v in out.values()) else out
|
|
283
|
+
|
|
284
|
+
def _describe(self) -> dict[str, Any]:
|
|
285
|
+
properties = {}
|
|
286
|
+
for name, member in self.fields.items():
|
|
287
|
+
described = member.schema.json_schema()
|
|
288
|
+
if not member.required and not callable(member.default):
|
|
289
|
+
described["default"] = member.default
|
|
290
|
+
properties[name] = described
|
|
291
|
+
out: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False}
|
|
292
|
+
required = [name for name, member in self.fields.items() if member.required]
|
|
293
|
+
if required:
|
|
294
|
+
out["required"] = required
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class OneOf(Schema):
|
|
299
|
+
"""One of several objects, told apart by the value of the member `tag`."""
|
|
300
|
+
|
|
301
|
+
def __init__(self, tag: str, variants: Mapping[str, Object], *, doc: str = ""):
|
|
302
|
+
super().__init__(doc)
|
|
303
|
+
self.tag, self.variants = tag, dict(variants)
|
|
304
|
+
|
|
305
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
306
|
+
if not isinstance(value, dict):
|
|
307
|
+
errors.append((path, f"expected an object, got {_type_name(value)}"))
|
|
308
|
+
return INVALID
|
|
309
|
+
tag = value.get(self.tag)
|
|
310
|
+
if tag not in self.variants:
|
|
311
|
+
errors.append((pointer(path, self.tag), f"must be one of {sorted(self.variants)}"))
|
|
312
|
+
return INVALID
|
|
313
|
+
rest = {k: v for k, v in value.items() if k != self.tag}
|
|
314
|
+
out = self.variants[tag].check(rest, path, errors)
|
|
315
|
+
return INVALID if out is INVALID else {self.tag: tag, **out}
|
|
316
|
+
|
|
317
|
+
def _describe(self) -> dict[str, Any]:
|
|
318
|
+
options = []
|
|
319
|
+
for tag, variant in self.variants.items():
|
|
320
|
+
described = variant.json_schema()
|
|
321
|
+
described["properties"] = {self.tag: {"const": tag}, **described["properties"]}
|
|
322
|
+
described["required"] = [self.tag, *described.get("required", [])]
|
|
323
|
+
options.append(described)
|
|
324
|
+
return {"oneOf": options}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_DURATION = re.compile(r"(\d+(?:\.\d+)?)\s*(s|m|h|d|w)")
|
|
328
|
+
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class Duration(Schema):
|
|
332
|
+
"""A duration: ``"90s"``, ``"15m"``, ``"12h"``, ``"7d"``, ``"2w"``, or a number of seconds (the normal form)."""
|
|
333
|
+
|
|
334
|
+
def __init__(self, *, allow_zero: bool = False, doc: str = ""):
|
|
335
|
+
super().__init__(doc)
|
|
336
|
+
self.allow_zero = allow_zero
|
|
337
|
+
|
|
338
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
339
|
+
if type(value) in (int, float):
|
|
340
|
+
seconds = value
|
|
341
|
+
elif isinstance(value, str) and _DURATION.fullmatch(value.strip()):
|
|
342
|
+
match = _DURATION.fullmatch(value.strip())
|
|
343
|
+
assert match is not None
|
|
344
|
+
seconds = float(match.group(1)) * _UNITS[match.group(2)]
|
|
345
|
+
if seconds == int(seconds):
|
|
346
|
+
seconds = int(seconds)
|
|
347
|
+
else:
|
|
348
|
+
errors.append((path, f"expected a duration such as \"7d\", \"12h\" or a number of seconds, "
|
|
349
|
+
f"got {value!r}"))
|
|
350
|
+
return INVALID
|
|
351
|
+
if not math.isfinite(seconds) or seconds < 0 or (seconds == 0 and not self.allow_zero):
|
|
352
|
+
errors.append((path, "must be positive" if not self.allow_zero else "must not be negative"))
|
|
353
|
+
return INVALID
|
|
354
|
+
return seconds
|
|
355
|
+
|
|
356
|
+
def _describe(self) -> dict[str, Any]:
|
|
357
|
+
return {"anyOf": [{"type": "string", "pattern": r"^\d+(\.\d+)?\s*(s|m|h|d|w)$"},
|
|
358
|
+
{"type": "number", "minimum": 0}]}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
_TIMESTAMP = re.compile(
|
|
362
|
+
r"(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?([Zz]|[+-]\d{2}:\d{2})")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def parse_timestamp(text: str) -> float:
|
|
366
|
+
"""Seconds since the epoch of an RFC 3339 timestamp with a time zone; ValueError if it is not one."""
|
|
367
|
+
match = _TIMESTAMP.fullmatch(text.strip())
|
|
368
|
+
if match is None:
|
|
369
|
+
raise ValueError(f"{text!r} is not an RFC 3339 timestamp such as 2026-11-01T00:00:00Z")
|
|
370
|
+
year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7))
|
|
371
|
+
zone = match.group(8)
|
|
372
|
+
offset = 0 if zone in ("Z", "z") else (1 if zone[0] == "+" else -1) * (int(zone[1:3]) * 60 + int(zone[4:6]))
|
|
373
|
+
moment = _dt.datetime(year, month, day, hour, minute, second, tzinfo=_dt.timezone(_dt.timedelta(minutes=offset)))
|
|
374
|
+
whole = int(moment.timestamp())
|
|
375
|
+
fraction = match.group(7)
|
|
376
|
+
return whole + float(fraction) if fraction else float(whole)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def format_timestamp(seconds: float) -> str:
|
|
380
|
+
"""RFC 3339, UTC, to the second (fractions are kept only if present)."""
|
|
381
|
+
moment = _dt.datetime.fromtimestamp(seconds, _dt.timezone.utc)
|
|
382
|
+
text = moment.strftime("%Y-%m-%dT%H:%M:%S")
|
|
383
|
+
fraction = seconds - math.floor(seconds)
|
|
384
|
+
if fraction:
|
|
385
|
+
text += f"{fraction:.6f}"[1:].rstrip("0")
|
|
386
|
+
return text + "Z"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class Timestamp(Schema):
|
|
390
|
+
"""A moment: an RFC 3339 string with a time zone, or seconds since the epoch. Normal form: seconds."""
|
|
391
|
+
|
|
392
|
+
def check(self, value: Any, path: str, errors: Errors) -> Any:
|
|
393
|
+
if type(value) in (int, float) and math.isfinite(value):
|
|
394
|
+
return value
|
|
395
|
+
if isinstance(value, _dt.datetime): # YAML reads unquoted timestamps as datetimes
|
|
396
|
+
if value.tzinfo is None:
|
|
397
|
+
errors.append((path, "a timestamp needs a time zone (for example a trailing Z)"))
|
|
398
|
+
return INVALID
|
|
399
|
+
return value.timestamp()
|
|
400
|
+
if isinstance(value, str):
|
|
401
|
+
try:
|
|
402
|
+
return parse_timestamp(value)
|
|
403
|
+
except ValueError as error:
|
|
404
|
+
errors.append((path, str(error)))
|
|
405
|
+
return INVALID
|
|
406
|
+
errors.append((path, f"expected an RFC 3339 timestamp or seconds since the epoch, got {_type_name(value)}"))
|
|
407
|
+
return INVALID
|
|
408
|
+
|
|
409
|
+
def _describe(self) -> dict[str, Any]:
|
|
410
|
+
return {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "number"}]}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
"""Simulations of a whole pipeline: how often it decides what, with the Monte Carlo error stated.
|
|
5
|
+
|
|
6
|
+
`planning.simulate` of sequential-analysis gives the operating
|
|
7
|
+
characteristics of one procedure. A library's pipeline adds more:
|
|
8
|
+
aggregation of raw events, several streams, decision rules. Its error
|
|
9
|
+
rates are measured by running the real pipeline many times
|
|
10
|
+
(`run_simulations`), each run with its own seeded random generator, and
|
|
11
|
+
counting outcomes (`proportion`). The interval of `proportion` is the
|
|
12
|
+
Wilson interval sequential-analysis uses for simulated power.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import random
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Callable, TypeVar
|
|
19
|
+
|
|
20
|
+
from sequential_analysis.planning import SimulationSummary
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_simulations(trial: Callable[[random.Random, int], T], runs: int, *, seed: int = 0) -> list[T]:
|
|
26
|
+
"""`trial(random.Random(seed + i), i)` for i in 0..runs-1: reproducible, and each run independent."""
|
|
27
|
+
if type(runs) is not int or runs < 1:
|
|
28
|
+
raise ValueError("runs must be a positive int")
|
|
29
|
+
return [trial(random.Random(seed + i), i) for i in range(runs)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Proportion:
|
|
34
|
+
"""How often something happened in simulated runs."""
|
|
35
|
+
|
|
36
|
+
hits: int
|
|
37
|
+
runs: int
|
|
38
|
+
level: float = 0.05 # the interval covers with probability 1 - level (Monte Carlo error)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def rate(self) -> float:
|
|
42
|
+
return self.hits / self.runs
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def interval(self) -> tuple[float, float]:
|
|
46
|
+
summary = SimulationSummary(self.runs, 0, self.hits, (), self.level)
|
|
47
|
+
return summary.power_interval
|
|
48
|
+
|
|
49
|
+
def to_json(self) -> dict:
|
|
50
|
+
lo, hi = self.interval
|
|
51
|
+
return {"hits": self.hits, "runs": self.runs, "rate": self.rate, "interval": [lo, hi],
|
|
52
|
+
"level": self.level}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def proportion(hits: int, runs: int, level: float = 0.05) -> Proportion:
|
|
56
|
+
if not (type(runs) is int and runs >= 1 and type(hits) is int and 0 <= hits <= runs):
|
|
57
|
+
raise ValueError("need 0 <= hits <= runs, runs >= 1")
|
|
58
|
+
return Proportion(hits, runs, level)
|