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,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)