feat: 新增单路流类型合同 stream-contract/1

冻结逐帧身份、采集时钟、测量头和事件信封:FrameIdentity、FrameClock、FrameStamp、MeasurementFrame、StreamEvent,并从包根导出。采用严格读取:未知字段和未列出的版本一律拒绝,任何字段变更都升版本,读取方在 SUPPORTED_CONTRACT_VERSIONS 中列出可读版本;未知事件 kind 仍兼容。事件可哈希,kind 为 lower_snake。合同写入 docs/stream-contract.zh.md;不接入现有运行时。窄套件 139 项、lint 通过。
This commit is contained in:
ykcheungab
2026-10-21 11:03:53 +08:00
parent cbccbd14b5
commit f6f3c45285
4 changed files with 1218 additions and 0 deletions
+23
View File
@@ -19,6 +19,18 @@ from .pipeline import (
StreamPipeline,
)
from .sensor import Sensor
from .stream_contract import (
STREAM_CONTRACT_VERSION,
SUPPORTED_CONTRACT_VERSIONS,
TIME_BASES,
FrameClock,
TimeBasis,
FrameIdentity,
FrameStamp,
MeasurementFrame,
StreamEvent,
)
#: The explicit force-readout session (``studio-force/1``) used by the Studio
#: workflow. Imported after ``Sensor`` because it composes the sensor, model and
#: output blocks; the historical ``Sensor.read_info``/``ForceReadout.apply``
@@ -70,6 +82,17 @@ __all__ = [
'ForceSession',
'WORKFLOW_VERSION',
# frozen single-stream contract (stream-contract/1)
'STREAM_CONTRACT_VERSION',
'SUPPORTED_CONTRACT_VERSIONS',
'TIME_BASES',
'TimeBasis',
'FrameIdentity',
'FrameClock',
'FrameStamp',
'MeasurementFrame',
'StreamEvent',
# configs utilities functions
'list_config',
'export_config',
+578
View File
@@ -0,0 +1,578 @@
"""Frozen public types for the single-stream SensorStream contract.
This module is the stage-1A **type contract** (task T03): the frame identity,
capture clock, measurement header and event envelope that the per-sensor stream
(T08), the rebuilt force session (T06) and the recording/analysis consumers
(T11, T13-T15) share. It deliberately does not change any existing class:
``Sensor``, ``StreamPipeline`` and the current ``ForceSession`` keep their
published behavior, and the later tasks adopt these headers instead of growing
per-layer variants.
The contract rules are documented in ``docs/stream-contract.zh.md``. In short:
* identity is per stream - ``sensor_id`` + ``session_id`` + source/original
frame number and a separate contiguous output number;
* a group identity (``acquisition_id`` + ``sync_group_id``) is optional and is
never fabricated: both parts are present or neither is;
* a capture time is a number of nanoseconds plus the clock domain and the basis
it was derived from; a raw source PTS is carried separately and is never
replaced by processing time;
* two frames with the same timestamp are still different frames (identity, not
the timestamp, is the deduplication key);
* ``source_dt_s`` is the source-time interval to the previous *actually
processed* frame of the same stream (``None`` for the first) and never a
processing-speed measurement;
* events round-trip through JSON with a JSON-safe payload.
Versioning follows one strict rule (policy A): **any new, renamed, removed or
re-typed field bumps ``STREAM_CONTRACT_VERSION``**. A reader rejects unknown
fields and only reads the versions it lists in
``SUPPORTED_CONTRACT_VERSIONS``; a writer that needs a field must bump the
version and both sides must list it. Event ``kind`` values are not schema
fields: consumers must ignore kinds they do not know, so new kinds stay
additive within the same version.
"""
from __future__ import annotations
import math
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Literal
__all__ = [
"STREAM_CONTRACT_VERSION",
"SUPPORTED_CONTRACT_VERSIONS",
"TIME_BASES",
"FrameClock",
"FrameIdentity",
"FrameStamp",
"MeasurementFrame",
"StreamEvent",
"TimeBasis",
]
#: Version of the serialized contract (``to_dict()``/``from_dict()``).
#: Policy A: bump it on every field addition, removal, rename or meaning
#: change; there are no same-version field additions.
STREAM_CONTRACT_VERSION = "stream-contract/1"
#: Serialized schemas this reader accepts, and the only ones it may read.
#: A writer that bumps ``STREAM_CONTRACT_VERSION`` must add the new version
#: here (and in every reader that must read it); old versions stay listed as
#: long as the loader supports them.
SUPPORTED_CONTRACT_VERSIONS: tuple[str, ...] = (STREAM_CONTRACT_VERSION,)
#: How ``FrameClock.capture_time_ns`` was derived.
#:
#: ``capture_monotonic`` - host monotonic capture/receive time of a live source.
#: ``recorded_signal`` - signal time stored by the recording (playback/debug).
#: ``video_pts`` - a real container PTS converted to nanoseconds.
#: ``estimated_source`` - derived from the source frame number and a nominal
#: rate because no trustworthy PTS exists; consumers must treat it as estimated.
TimeBasis = Literal[
"capture_monotonic",
"recorded_signal",
"video_pts",
"estimated_source",
]
TIME_BASES: tuple[TimeBasis, ...] = (
"capture_monotonic",
"recorded_signal",
"video_pts",
"estimated_source",
)
#: Event kinds are a stable lower_snake identifier; this grammar is part of the
#: frozen envelope contract (unknown *values* stay compatible, malformed ones do
#: not).
_EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_]*$")
# --------------------------------------------------------------- validation
def _require_str(value: Any, name: str) -> str:
if not isinstance(value, str):
raise TypeError(f"{name} must be a string")
if not value:
raise ValueError(f"{name} must not be empty")
return value
def _require_int(value: Any, name: str, *, minimum: int | None = None) -> int:
# ``type`` rather than ``isinstance``: a bool is an int in Python but never a
# frame number or generation.
if type(value) is not int:
raise TypeError(f"{name} must be an integer")
if minimum is not None and value < minimum:
raise ValueError(f"{name} must be >= {minimum}")
return value
def _require_optional_int(
value: Any, name: str, *, minimum: int | None = None
) -> int | None:
if value is None:
return None
return _require_int(value, name, minimum=minimum)
def _require_finite_number(value: Any, name: str) -> int | float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a real number")
if not math.isfinite(float(value)):
raise ValueError(f"{name} must be finite")
return value
def _require_time_basis(value: Any) -> TimeBasis:
if value not in TIME_BASES:
raise ValueError(
f"capture_time_basis must be one of {list(TIME_BASES)}, got {value!r}"
)
return value
def _require_source_dt(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError("source_dt_s must be a real number or None")
number = float(value)
if not math.isfinite(number):
raise ValueError("source_dt_s must be finite")
if number < 0.0:
raise ValueError("source_dt_s must be >= 0")
return number
# ----------------------------------------------------------- JSON payloads
def _freeze_json(value: Any, path: str) -> Any:
"""Validate a JSON-serializable value and return an immutable deep copy.
Only JSON types are accepted (``None``/bool/int/finite float/str/mapping
with string keys/list/tuple). ``NaN`` and infinities are rejected because
they do not survive ``json.dumps``/``json.loads`` unchanged.
"""
if value is None or isinstance(value, (bool, str)):
return value
if isinstance(value, int):
return value
if isinstance(value, float):
if not math.isfinite(value):
raise ValueError(f"{path} contains a non-finite float")
return value
if isinstance(value, Mapping):
frozen: dict[str, Any] = {}
for key, item in value.items():
if not isinstance(key, str):
raise TypeError(
f"{path} keys must be strings, got {type(key).__name__}"
)
frozen[key] = _freeze_json(item, f"{path}.{key}")
return MappingProxyType(frozen)
if isinstance(value, (list, tuple)):
return tuple(
_freeze_json(item, f"{path}[{index}]") for index, item in enumerate(value)
)
raise TypeError(f"{path} contains non-JSON value {type(value).__name__}")
def _thaw_json(value: Any) -> Any:
if isinstance(value, Mapping):
return {key: _thaw_json(item) for key, item in value.items()}
if isinstance(value, tuple):
return [_thaw_json(item) for item in value]
return value
def _mapping(
data: Any, name: str, allowed: frozenset[str], required: frozenset[str]
) -> Mapping:
"""Read one serialized object under policy A (strict field set).
Unknown fields are rejected, not ignored: a same-version record may only
contain the fields of that frozen version. A new field therefore requires a
new ``STREAM_CONTRACT_VERSION`` and an entry in
``SUPPORTED_CONTRACT_VERSIONS``; components without a schema (identity and
clock) follow the same allow-list rule through their owners.
"""
if not isinstance(data, Mapping):
raise TypeError(f"{name} must be a mapping")
unknown = set(data) - allowed
if unknown:
raise ValueError(
f"{name} has unknown field(s) {sorted(unknown)}; adding a field "
"requires a new STREAM_CONTRACT_VERSION"
)
missing = required - set(data)
if missing:
raise ValueError(f"{name} is missing field(s) {sorted(missing)}")
return data
def _require_schema(value: Any, name: str) -> str:
"""Accept only the serialized versions this reader explicitly lists."""
if not isinstance(value, str):
raise TypeError(f"{name}.schema must be a string")
if value not in SUPPORTED_CONTRACT_VERSIONS:
raise ValueError(
f"{name}.schema must be one of {list(SUPPORTED_CONTRACT_VERSIONS)}, "
f"got {value!r}"
)
return value
# ------------------------------------------------------------- frame types
@dataclass(frozen=True)
class FrameIdentity:
"""Identity of one frame in one stream.
``source_frame_id`` keeps the source's own frame number (``None`` when the
source does not provide one); ``frame_id`` is the contiguous output number
of this stream. Both are needed: the same timestamp does not identify a
frame, and two different source frames may share a PTS.
``acquisition_id``/``sync_group_id`` describe an optional capture group
(for example one hardware trigger spanning several sensors). They are
both present or both ``None``; a missing group is not replaced with a
fabricated value and does not imply synchronization.
"""
sensor_id: str
session_id: str
frame_id: int
source_frame_id: int | None
acquisition_id: str | None = None
sync_group_id: str | None = None
def __post_init__(self) -> None:
_require_str(self.sensor_id, "sensor_id")
_require_str(self.session_id, "session_id")
_require_int(self.frame_id, "frame_id", minimum=0)
_require_optional_int(self.source_frame_id, "source_frame_id", minimum=0)
if (self.acquisition_id is None) != (self.sync_group_id is None):
raise ValueError(
"group identity is optional but must be complete: set both "
"acquisition_id and sync_group_id, or leave both None"
)
if self.acquisition_id is not None:
_require_str(self.acquisition_id, "acquisition_id")
_require_str(self.sync_group_id, "sync_group_id")
@property
def has_group(self) -> bool:
"""Whether this frame carries a capture-group identity."""
return self.acquisition_id is not None
@property
def group_identity(self) -> tuple[str, str] | None:
"""``(acquisition_id, sync_group_id)``, or ``None`` when absent."""
if self.acquisition_id is None:
return None
return (str(self.acquisition_id), str(self.sync_group_id))
def to_dict(self) -> dict[str, Any]:
return {
"sensor_id": self.sensor_id,
"session_id": self.session_id,
"frame_id": self.frame_id,
"source_frame_id": self.source_frame_id,
"acquisition_id": self.acquisition_id,
"sync_group_id": self.sync_group_id,
}
@classmethod
def from_dict(cls, data: Mapping) -> FrameIdentity:
allowed = frozenset(
{
"sensor_id",
"session_id",
"frame_id",
"source_frame_id",
"acquisition_id",
"sync_group_id",
}
)
required = frozenset({"sensor_id", "session_id", "frame_id", "source_frame_id"})
fields = _mapping(data, "FrameIdentity", allowed, required)
return cls(
sensor_id=fields["sensor_id"],
session_id=fields["session_id"],
frame_id=fields["frame_id"],
source_frame_id=fields["source_frame_id"],
acquisition_id=fields.get("acquisition_id"),
sync_group_id=fields.get("sync_group_id"),
)
@dataclass(frozen=True)
class FrameClock:
"""Capture time of one frame, with its clock domain and time basis.
``capture_time_ns`` is always nanoseconds, but what it means is declared by
``capture_time_basis``; ``clock_id`` names the clock domain (for example
``host_monotonic`` or a source/video clock identity). ``source_pts`` keeps
the source's own presentation timestamp untouched (its unit is the source's
declared timebase); it is never replaced by processing time or by an
estimated value. A result's *published* time is recorded separately by the
result envelope, not here.
"""
capture_time_ns: int
clock_id: str
capture_time_basis: TimeBasis
source_pts: int | float | None = None
def __post_init__(self) -> None:
_require_int(self.capture_time_ns, "capture_time_ns", minimum=0)
_require_str(self.clock_id, "clock_id")
_require_time_basis(self.capture_time_basis)
if self.source_pts is not None:
_require_finite_number(self.source_pts, "source_pts")
def to_dict(self) -> dict[str, Any]:
return {
"capture_time_ns": self.capture_time_ns,
"clock_id": self.clock_id,
"capture_time_basis": self.capture_time_basis,
"source_pts": self.source_pts,
}
@classmethod
def from_dict(cls, data: Mapping) -> FrameClock:
allowed = frozenset(
{"capture_time_ns", "clock_id", "capture_time_basis", "source_pts"}
)
required = frozenset({"capture_time_ns", "clock_id", "capture_time_basis"})
fields = _mapping(data, "FrameClock", allowed, required)
return cls(
capture_time_ns=fields["capture_time_ns"],
clock_id=fields["clock_id"],
capture_time_basis=fields["capture_time_basis"],
source_pts=fields.get("source_pts"),
)
@dataclass(frozen=True)
class FrameStamp:
"""Per-stream frame identity plus capture clock; the shared frame header.
The flat fields are the serialized contract. ``identity`` and ``clock``
expose the two components for code that wants to carry them independently.
"""
sensor_id: str
session_id: str
frame_id: int
source_frame_id: int | None
capture_time_ns: int
clock_id: str
capture_time_basis: TimeBasis
source_pts: int | float | None = None
acquisition_id: str | None = None
sync_group_id: str | None = None
def __post_init__(self) -> None:
# Validate through the components so every frame header obeys the same
# rules as a separately supplied identity/clock pair.
self.identity
self.clock
@classmethod
def from_parts(cls, identity: FrameIdentity, clock: FrameClock) -> FrameStamp:
"""Build a stamp from its two component types."""
if not isinstance(identity, FrameIdentity):
raise TypeError("identity must be a FrameIdentity")
if not isinstance(clock, FrameClock):
raise TypeError("clock must be a FrameClock")
return cls(
sensor_id=identity.sensor_id,
session_id=identity.session_id,
frame_id=identity.frame_id,
source_frame_id=identity.source_frame_id,
capture_time_ns=clock.capture_time_ns,
clock_id=clock.clock_id,
capture_time_basis=clock.capture_time_basis,
source_pts=clock.source_pts,
acquisition_id=identity.acquisition_id,
sync_group_id=identity.sync_group_id,
)
@property
def identity(self) -> FrameIdentity:
return FrameIdentity(
sensor_id=self.sensor_id,
session_id=self.session_id,
frame_id=self.frame_id,
source_frame_id=self.source_frame_id,
acquisition_id=self.acquisition_id,
sync_group_id=self.sync_group_id,
)
@property
def clock(self) -> FrameClock:
return FrameClock(
capture_time_ns=self.capture_time_ns,
clock_id=self.clock_id,
capture_time_basis=self.capture_time_basis,
source_pts=self.source_pts,
)
def to_dict(self) -> dict[str, Any]:
return {
"schema": STREAM_CONTRACT_VERSION,
**self.identity.to_dict(),
**self.clock.to_dict(),
}
@classmethod
def from_dict(cls, data: Mapping) -> FrameStamp:
allowed = frozenset(
{
"schema",
"sensor_id",
"session_id",
"frame_id",
"source_frame_id",
"capture_time_ns",
"clock_id",
"capture_time_basis",
"source_pts",
"acquisition_id",
"sync_group_id",
}
)
required = frozenset(
{
"schema",
"sensor_id",
"session_id",
"frame_id",
"source_frame_id",
"capture_time_ns",
"clock_id",
"capture_time_basis",
}
)
fields = _mapping(data, "FrameStamp", allowed, required)
_require_schema(fields["schema"], "FrameStamp")
return cls(
sensor_id=fields["sensor_id"],
session_id=fields["session_id"],
frame_id=fields["frame_id"],
source_frame_id=fields["source_frame_id"],
capture_time_ns=fields["capture_time_ns"],
clock_id=fields["clock_id"],
capture_time_basis=fields["capture_time_basis"],
source_pts=fields.get("source_pts"),
acquisition_id=fields.get("acquisition_id"),
sync_group_id=fields.get("sync_group_id"),
)
@dataclass(frozen=True)
class MeasurementFrame:
"""Minimal measurement header handed from the image block to force session.
``reference_generation`` is the optical-flow reference version that was in
effect when this frame's flow was computed (the image thread writes it; it
is the ``FrameSnapshot.tracker_reset_seq``/``request_reset`` target concept
from stage 0). ``source_dt_s`` is the source-time distance to the previous
*actually processed* frame of this stream, or ``None`` for the first one.
A long interval is data for a quality marker and a warning; it never resets
a reference, filter or zero by itself.
The measurement payload (flow/HHD/field/contact) and the force result are
defined by T05/T06 and the result envelope; this header is the part frozen
now.
"""
stamp: FrameStamp
source_dt_s: float | None
reference_generation: int = 0
def __post_init__(self) -> None:
if not isinstance(self.stamp, FrameStamp):
raise TypeError("stamp must be a FrameStamp")
_require_source_dt(self.source_dt_s)
_require_int(self.reference_generation, "reference_generation", minimum=0)
def to_dict(self) -> dict[str, Any]:
return {
"schema": STREAM_CONTRACT_VERSION,
"stamp": self.stamp.to_dict(),
"source_dt_s": None
if self.source_dt_s is None
else float(self.source_dt_s),
"reference_generation": self.reference_generation,
}
@classmethod
def from_dict(cls, data: Mapping) -> MeasurementFrame:
allowed = frozenset({"schema", "stamp", "source_dt_s", "reference_generation"})
required = frozenset({"schema", "stamp", "source_dt_s"})
fields = _mapping(data, "MeasurementFrame", allowed, required)
_require_schema(fields["schema"], "MeasurementFrame")
return cls(
stamp=FrameStamp.from_dict(fields["stamp"]),
source_dt_s=fields["source_dt_s"],
reference_generation=fields.get("reference_generation", 0),
)
@dataclass(frozen=True)
class StreamEvent:
"""One serializable event of a stream's lifecycle.
``kind`` is a stable lower_snake identifier matching
``^[a-z][a-z0-9_]*$`` (for example ``reference_reset``,
``zero_requested``, ``source_gap``); consumers must ignore kinds they do not
know instead of failing, so new kinds are additive. ``stamp`` is the frame
the event belongs to when there is one. ``payload`` must be JSON-safe and is
frozen/validated on construction; the frozen payload is excluded from the
hash so equal events hash equally while ``hash(event)`` stays usable.
``event_version`` versions the envelope itself (``STREAM_CONTRACT_VERSION``
versions the field contract).
"""
kind: str
stamp: FrameStamp | None = None
payload: Mapping[str, Any] = field(default_factory=dict, hash=False)
event_version: int = 1
def __post_init__(self) -> None:
_require_str(self.kind, "kind")
if _EVENT_KIND_RE.fullmatch(self.kind) is None:
raise ValueError(f"kind must match ^[a-z][a-z0-9_]*$, got {self.kind!r}")
if self.stamp is not None and not isinstance(self.stamp, FrameStamp):
raise TypeError("stamp must be a FrameStamp or None")
_require_int(self.event_version, "event_version", minimum=1)
if not isinstance(self.payload, Mapping):
raise TypeError("payload must be a mapping")
object.__setattr__(self, "payload", _freeze_json(self.payload, "payload"))
def to_dict(self) -> dict[str, Any]:
return {
"schema": STREAM_CONTRACT_VERSION,
"kind": self.kind,
"event_version": self.event_version,
"stamp": None if self.stamp is None else self.stamp.to_dict(),
"payload": _thaw_json(self.payload),
}
@classmethod
def from_dict(cls, data: Mapping) -> StreamEvent:
allowed = frozenset({"schema", "kind", "event_version", "stamp", "payload"})
required = frozenset({"schema", "kind"})
fields = _mapping(data, "StreamEvent", allowed, required)
_require_schema(fields["schema"], "StreamEvent")
stamp = fields.get("stamp")
return cls(
kind=fields["kind"],
stamp=None if stamp is None else FrameStamp.from_dict(stamp),
payload=fields.get("payload") or {},
event_version=fields.get("event_version", 1),
)