Metadata-Version: 2.5
Name: dsg-run-events
Version: 0.1.1
Summary: Typed simulation events, canonical catalogs, and offline stream verification.
Project-URL: Source, https://github.com/DynamicalSystemsGroup/dsg-run-events
Requires-Python: >=3.12
Requires-Dist: jsonschema<5,>=4.25
Requires-Dist: pydantic<3,>=2.11
Requires-Dist: referencing<1,>=0.36
Requires-Dist: rfc8785<1,>=0.1.4
Description-Content-Type: text/markdown

# dsg-run-events

Declare typed simulation events, write them to standard output, and check a recorded run offline.
The library opens no network connection. The executor collects lines beginning with `DSGEV `.

## First event

Install Python 3.12 or newer and [uv](https://docs.astral.sh/uv/). In your simulation project:

```sh
uv add dsg-run-events
```

Save this as **catalog.py**:

```python
from dsg_run_events import Catalog, Event, Hex64, SafeInt

catalog = Catalog("example-simulation", version=1)


@catalog.define("block.completed")
class BlockDone(Event):
    """One deterministic simulation step and its chain head."""

    block: SafeInt
    head: Hex64
    temperature: float
    label: str
```

These are real Pydantic v2 classes. Editors know the fields and type checkers reject wrong constructor
arguments. Use Python annotations, `Literal` for enums, and Pydantic's `Field` for constraints.
Undeclared payload fields fail. Do not add custom validators whose rules cannot be exported to JSON
Schema: an offline consumer cannot execute your Python. Emission also checks the exported schema.

Save this as **simulate.py**:

```python
import argparse

from catalog import BlockDone, catalog

from dsg_run_events import Emitter, Finished, Started

parser = argparse.ArgumentParser()
parser.add_argument("--job", action="store_true", help="Require the plane's environment")
parser.add_argument(
    "--replay", action="store_true", help="Use a fixed clock for byte-identical output"
)
args = parser.parse_args()

if args.job:
    emitter = Emitter.from_environment(catalog)
elif args.replay:
    emitter = Emitter.local(catalog, clock=lambda: "2026-09-07T15:00:00Z")
else:
    emitter = Emitter.local(catalog)

emitter.emit(Started(version="1.0.0", seed=7))
emitter.emit(BlockDone(block=1, head="ab" * 32, temperature=1e-7, label="café"))
emitter.emit(Finished(status="succeeded", summary="one block"))
```

Run, inspect, and export:

```sh
uv run dsg-run-events export catalog.py
uv run python simulate.py --replay > run.log
uv run dsg-run-events tail run.log
uv run dsg-run-events check catalog.json run.log
uv run dsg-run-events check catalog.json - < run.log
```

Export writes `catalog.json` and prints its lowercase SHA-256. Commit the JSON file beside the
simulation and `uv.lock`. Put that digest in the operation's catalog input; do not hash it by hand.
The digest is SHA-256 of the file's RFC 8785 canonical bytes, with no trailing newline. Reformatting
JSON does not change its canonical digest, but changes the literal file digest; keep the exported bytes.
Export executes the Python file. Only export code you trust.

## Governed runs

Install the pinned package while building the simulation image, not while running the job.
The plane supplies `DSG_OPERATION_ID` and `DSG_CATALOG_SHA256`; run `simulate.py --job`.
Missing identity, the local identity, a missing digest, or a different digest refuses before any output.
The simulation image is pinned by its own digest in the operation request.

`Emitter.local(catalog)` deliberately ignores job environment variables and uses operation `local`.
There is no implicit fallback. Exporting, hashing, or creating an emitter freezes the catalog;
finish declaring models first. A returned document is a copy, not mutable catalog state.

## Stream contract

Every catalog includes `Started`, `PhaseEntered`, `PhaseExited`, `Progress`, `Checkpoint`,
`Artifact`, `Warning`, `Failure`, and `Finished`. Import them from `dsg_run_events` and pass
instances to `emitter.emit(...)`. Each model documents its meaning and exports its payload schema.
Artifacts carry a digest, size, and optional path, never file contents.

Event envelopes conform to the pinned shared definition `dsg.run.event/1`. The cap is 65,536 UTF-8
bytes for the JSON body, excluding the prefix and line terminator. Times have exact UTC-second syntax.
Sequences start at one. Ordering is by sequence, not clock. Payload numbers must be finite; use
`SafeInt` for integers within the interoperable JSON range. Canonicalization refuses larger integers.

The stream digest covers unique canonical event objects sorted by sequence, joined by a single LF,
without `DSGEV ` prefixes or a final LF. Identical duplicate delivery has no effect. Conflicting
same-sequence events, mixed operation identities, unknown kinds, invalid payloads, malformed prefixed
lines, and catalog mismatches fail the local check. Reordered delivery has the same digest but reports
an ordering fault. Gaps are inclusive `[first, last]` ranges to bound memory even for a hostile sequence.
Ordinary log output is ignored. An empty stream has the SHA-256 of empty bytes; this is not proof that
a simulation completed. The plane's recorded lifecycle and outcome provide that evidence.

`clock=` accepts a callable returning a UTC-second string. `sequence=` accepts a callable returning
the next contiguous integer. Defaults use the real clock and an internal counter. One emitter can be
shared by threads: validation, sequence assignment, write, and flush hold one lock. Use one emitter
per operation. Multiple processes must send observations to one owning writer. A forked emitter is
refused. After an I/O failure the writer cannot be retried because a partial line may already exist.

Payload construction raises Pydantic `ValidationError`: `errors()` supplies field locations, expected
types, and received inputs; its title identifies the model. Boundary failures raise `EventError` with
`problems`, each containing `kind`, `field`, `expected`, and `received`. The check report uses
that same structure. Do not catch errors and continue a governed run as if it succeeded.

## Commands and exit codes

Run `dsg-run-events --help` or a subcommand with `--help`.

- `export catalog.py [-o catalog.json]`: write JSON Schema and print its digest.
- `tail run.log`: render event rows. Use `-` for standard input.
- `check catalog.json run.log`: print a JSON verdict. Use `-` for the log's standard input.
- Exit **0**: success or a clean recorded stream.
- Exit **1**: check found invalid events, gaps, unknown kinds, or ordering faults.
- Exit **2**: usage, file, catalog, or parsing error outside a check report.

## Engine integration

Keep the engine model-agnostic. Its own listener protocol has three methods:
`on_block(block: int, head: str)`, `on_phase(name: str, entered: bool)`, and
`on_artifact(path: str, digest: str)`, all returning `None`. Default to no-op methods in the engine.
It need not import this package. `NullListener` is available for CLI composition and tests.

The CLI constructs `EventListener(emitter, block_event)`, where `block_event` is a callable taking
`(block, head)` and returning the catalog's typed payload. Pass that listener into the engine.
The adapter emits block events, phase entry/exit, and artifact references. Artifact callbacks require
a completed file because the adapter reads its size. Callback failures propagate to the CLI;
the listener never returns an input to the simulation. Replay uses injected clocks and sequences.

## Development and releases

```sh
mise install
mise exec -- uv sync --python "$(mise where python)/bin/python"
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pytest
uv build
```

The tests execute the README's Python examples and CLI commands, check a golden recorded stream,
and use Hypothesis at validation boundaries. All twelve shared accept/reject vectors are vendored
unchanged from `@dynamicalsystems/idl-conformance` 0.7.1, commit
`83842b76dd02eb333ebbe4893d8a8b56b732e673`. File hashes and paths are in `tests/idl-pin.json`.
The packaged wire schema is generated from that commit's TypeBox definitions, not redeclared here.
The upstream seal vectors contain shape-only digest placeholders, not event-line preimages.
Separate stream fixtures exercise RFC 8785 floats, UTF-8, ordering, and truncation gaps against
independently computed JavaScript canonical digests.

CI checks every pull request and main commit. A release tag must point to a previously green main
commit; the release also reruns all checks. CI builds once, signs the wheel and source archive with
Sigstore, attaches them to GitHub Releases, and publishes them to PyPI with attestations using
Trusted Publishing. No laptop publishing, stored PyPI token, or reused release tag.
The repository is private; the PyPI artifacts and the Python source they contain are public.
