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:
@@ -0,0 +1,441 @@
|
||||
"""The frozen single-stream type contract: identity, clock, source_dt_s, events.
|
||||
|
||||
The contract is defined in ``orisys.stream_contract`` and documented in
|
||||
``docs/stream-contract.zh.md``. These tests pin the four acceptance rules of
|
||||
T03: per-stream identity and clock, an optional-but-never-partial group
|
||||
identity, distinguishability of frames that share a timestamp, and JSON
|
||||
round-trips for ``source_dt_s`` and events. They are pure type tests: nothing
|
||||
here starts a sensor, a pipeline or a session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import (
|
||||
STREAM_CONTRACT_VERSION,
|
||||
SUPPORTED_CONTRACT_VERSIONS,
|
||||
FrameClock,
|
||||
FrameIdentity,
|
||||
FrameStamp,
|
||||
MeasurementFrame,
|
||||
StreamEvent,
|
||||
)
|
||||
|
||||
|
||||
def _round_trip(value):
|
||||
"""JSON round-trip exactly as a recording would read the contract back."""
|
||||
payload = json.loads(json.dumps(value.to_dict()))
|
||||
return type(value).from_dict(payload)
|
||||
|
||||
|
||||
def _stamp(**overrides) -> FrameStamp:
|
||||
fields = {
|
||||
"sensor_id": "FINGER-001",
|
||||
"session_id": "run-7",
|
||||
"frame_id": 12,
|
||||
"source_frame_id": 41,
|
||||
"capture_time_ns": 1_600_000_000_000_000_000,
|
||||
"clock_id": "host_monotonic",
|
||||
"capture_time_basis": "capture_monotonic",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return FrameStamp(**fields)
|
||||
|
||||
|
||||
def test_the_contract_version_is_exported_and_explicit() -> None:
|
||||
assert STREAM_CONTRACT_VERSION == "stream-contract/1"
|
||||
# Every frozen type and the version are importable from the package root,
|
||||
# which is the public boundary downstream repositories import.
|
||||
import orisys
|
||||
|
||||
for name in (
|
||||
"FrameIdentity",
|
||||
"FrameClock",
|
||||
"FrameStamp",
|
||||
"MeasurementFrame",
|
||||
"StreamEvent",
|
||||
"TIME_BASES",
|
||||
"STREAM_CONTRACT_VERSION",
|
||||
):
|
||||
assert hasattr(orisys, name), name
|
||||
|
||||
|
||||
# --------------------------------------------------------------- identity
|
||||
def test_identity_keeps_a_per_stream_output_number_and_the_source_number() -> None:
|
||||
identity = FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=12,
|
||||
source_frame_id=41,
|
||||
)
|
||||
assert (identity.sensor_id, identity.session_id) == ("BOX-3", "run-7")
|
||||
assert identity.frame_id == 12
|
||||
assert identity.source_frame_id == 41
|
||||
# A source without its own numbering may say so; the output number still exists.
|
||||
identity = FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=None
|
||||
)
|
||||
assert identity.frame_id == 0 and identity.source_frame_id is None
|
||||
with pytest.raises(ValueError):
|
||||
FrameIdentity(
|
||||
sensor_id="", session_id="run-7", frame_id=0, source_frame_id=None
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=-1, source_frame_id=None
|
||||
)
|
||||
with pytest.raises(TypeError):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=True, source_frame_id=None
|
||||
)
|
||||
|
||||
|
||||
def test_group_identity_is_optional_but_never_partial() -> None:
|
||||
without_group = FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=0
|
||||
)
|
||||
assert without_group.has_group is False
|
||||
assert without_group.group_identity is None
|
||||
assert without_group.to_dict()["acquisition_id"] is None
|
||||
assert without_group.to_dict()["sync_group_id"] is None
|
||||
|
||||
grouped = FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
acquisition_id="acq-1",
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
assert grouped.has_group is True
|
||||
assert grouped.group_identity == ("acq-1", "group-1")
|
||||
assert _round_trip(grouped) == grouped
|
||||
|
||||
# A half group would look like synchronization without an owner.
|
||||
with pytest.raises(ValueError, match="both acquisition_id and sync_group_id"):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
acquisition_id="acq-1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="both acquisition_id and sync_group_id"):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
acquisition_id="",
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ clock
|
||||
def test_clock_keeps_the_time_domain_basis_and_the_raw_source_pts() -> None:
|
||||
clock = FrameClock(
|
||||
capture_time_ns=1_600_000_000_000_000_000,
|
||||
clock_id="video:recording-4",
|
||||
capture_time_basis="video_pts",
|
||||
source_pts=90000,
|
||||
)
|
||||
assert clock.capture_time_basis == "video_pts"
|
||||
assert clock.source_pts == 90000
|
||||
# A derived file timestamp is explicitly labelled as estimated.
|
||||
assert FrameClock(
|
||||
0, "source:frame-numbers", "estimated_source"
|
||||
).capture_time_basis == ("estimated_source")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(0, "host", "wall_clock")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(-1, "host", "capture_monotonic")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(0, "", "capture_monotonic")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(0, "host", "capture_monotonic", source_pts=float("nan"))
|
||||
with pytest.raises(TypeError):
|
||||
FrameClock(0, "host", "capture_monotonic", source_pts=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ stamp
|
||||
def test_stamp_composes_identity_and_clock_and_round_trips() -> None:
|
||||
identity = FrameIdentity(
|
||||
sensor_id="FINGER-001",
|
||||
session_id="run-7",
|
||||
frame_id=12,
|
||||
source_frame_id=41,
|
||||
acquisition_id="acq-1",
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
clock = FrameClock(
|
||||
capture_time_ns=1_600_000_000_000_000_000,
|
||||
clock_id="host_monotonic",
|
||||
capture_time_basis="capture_monotonic",
|
||||
source_pts=41.5,
|
||||
)
|
||||
stamp = FrameStamp.from_parts(identity, clock)
|
||||
assert stamp.identity == identity
|
||||
assert stamp.clock == clock
|
||||
|
||||
payload = stamp.to_dict()
|
||||
assert payload["schema"] == STREAM_CONTRACT_VERSION
|
||||
assert payload["sensor_id"] == "FINGER-001"
|
||||
assert payload["source_frame_id"] == 41
|
||||
assert payload["capture_time_ns"] == clock.capture_time_ns
|
||||
assert _round_trip(stamp) == stamp
|
||||
|
||||
# The flattened fields are the same objects the components report.
|
||||
assert stamp.sensor_id == identity.sensor_id
|
||||
assert stamp.session_id == identity.session_id
|
||||
assert stamp.frame_id == identity.frame_id
|
||||
assert stamp.source_frame_id == identity.source_frame_id
|
||||
assert stamp.capture_time_ns == clock.capture_time_ns
|
||||
assert stamp.clock_id == clock.clock_id
|
||||
assert stamp.capture_time_basis == clock.capture_time_basis
|
||||
assert stamp.source_pts == clock.source_pts
|
||||
assert (stamp.acquisition_id, stamp.sync_group_id) == ("acq-1", "group-1")
|
||||
|
||||
|
||||
def test_frames_that_share_a_pts_are_still_distinct_frames() -> None:
|
||||
"""Timestamps are not identity: a recording must keep both frames."""
|
||||
first = _stamp(source_frame_id=41, frame_id=10)
|
||||
second = _stamp(source_frame_id=42, frame_id=11)
|
||||
assert first.capture_time_ns == second.capture_time_ns
|
||||
assert first.source_pts == second.source_pts
|
||||
assert first != second
|
||||
assert first.to_dict() != second.to_dict()
|
||||
assert _round_trip(first) != _round_trip(second)
|
||||
|
||||
# A source without its own numbering can still emit repeated timestamps;
|
||||
# the contiguous output number keeps the frames apart.
|
||||
first = _stamp(source_frame_id=None, frame_id=10)
|
||||
second = _stamp(source_frame_id=None, frame_id=11)
|
||||
assert first != second
|
||||
assert (first.capture_time_ns, first.source_pts) == (
|
||||
second.capture_time_ns,
|
||||
second.source_pts,
|
||||
)
|
||||
|
||||
# Different sessions are different streams even with identical numbers.
|
||||
other_session = _stamp(session_id="run-8")
|
||||
assert other_session != first and other_session != second
|
||||
|
||||
|
||||
# -------------------------------------------------------- measurement frame
|
||||
def test_measurement_frame_carries_source_dt_s_and_round_trips() -> None:
|
||||
stamp = _stamp()
|
||||
first = MeasurementFrame(stamp=stamp, source_dt_s=None)
|
||||
assert first.source_dt_s is None
|
||||
assert first.reference_generation == 0
|
||||
assert _round_trip(first) == first
|
||||
|
||||
following = MeasurementFrame(
|
||||
stamp=_stamp(frame_id=13, source_frame_id=42),
|
||||
source_dt_s=0.033,
|
||||
reference_generation=3,
|
||||
)
|
||||
payload = following.to_dict()
|
||||
assert payload["schema"] == STREAM_CONTRACT_VERSION
|
||||
assert payload["source_dt_s"] == pytest.approx(0.033)
|
||||
assert payload["reference_generation"] == 3
|
||||
assert _round_trip(following) == following
|
||||
|
||||
# Equal source timestamps are valid data; they do not collapse frames and do
|
||||
# not produce a negative/None interval.
|
||||
same_time = MeasurementFrame(stamp=_stamp(), source_dt_s=0.0)
|
||||
assert same_time.source_dt_s == 0.0
|
||||
|
||||
|
||||
def test_measurement_frame_rejects_invalid_source_dt_and_generation() -> None:
|
||||
stamp = _stamp()
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=-0.001)
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=float("nan"))
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=float("inf"))
|
||||
with pytest.raises(TypeError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=True)
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=None, reference_generation=-1)
|
||||
with pytest.raises(TypeError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=None, reference_generation=1.5)
|
||||
with pytest.raises(TypeError):
|
||||
MeasurementFrame(stamp={}, source_dt_s=None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ events
|
||||
def test_events_round_trip_through_json_with_a_frozen_payload() -> None:
|
||||
stamp = _stamp()
|
||||
event = StreamEvent(
|
||||
kind="source_gap",
|
||||
stamp=stamp,
|
||||
payload={
|
||||
"dropped_frames": 3,
|
||||
"source_dt_s": 0.5,
|
||||
"proportion": 0.25,
|
||||
"flags": [True, False, None],
|
||||
"context": {"reason": "overload", "counts": [1, 2]},
|
||||
},
|
||||
event_version=1,
|
||||
)
|
||||
assert event.payload["dropped_frames"] == 3
|
||||
# The payload is read-only after construction.
|
||||
with pytest.raises(TypeError):
|
||||
event.payload["dropped_frames"] = 4 # type: ignore[index]
|
||||
|
||||
payload = event.to_dict()
|
||||
assert payload["schema"] == STREAM_CONTRACT_VERSION
|
||||
assert payload["event_version"] == 1
|
||||
assert _round_trip(event) == event
|
||||
|
||||
# A lifecycle event may have no frame, and unknown kinds stay additive.
|
||||
event = StreamEvent(kind="stream_started", payload={})
|
||||
assert event.stamp is None
|
||||
assert _round_trip(event) == event
|
||||
assert _round_trip(StreamEvent(kind="some_future_kind", payload={"x": 1})).kind == (
|
||||
"some_future_kind"
|
||||
)
|
||||
|
||||
|
||||
def test_events_reject_payloads_that_cannot_round_trip() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="bad", payload={"nan": float("nan")})
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="bad", payload={"inf": float("inf")})
|
||||
with pytest.raises(TypeError):
|
||||
StreamEvent(kind="bad", payload={1: "non-string key"})
|
||||
with pytest.raises(TypeError):
|
||||
StreamEvent(kind="bad", payload={"bytes": b"raw"})
|
||||
with pytest.raises(TypeError):
|
||||
StreamEvent(kind="bad", payload={"set": {1, 2}})
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="", payload={})
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="bad", payload={}, event_version=0)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- read-back
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[
|
||||
lambda: _stamp(),
|
||||
lambda: _stamp(acquisition_id="acq-1", sync_group_id="group-1"),
|
||||
lambda: MeasurementFrame(_stamp(), None),
|
||||
lambda: MeasurementFrame(_stamp(), 0.033, 2),
|
||||
lambda: StreamEvent("reference_reset", _stamp(), {"from": 1, "to": 2}),
|
||||
],
|
||||
)
|
||||
def test_every_frozen_type_round_trips_through_its_dictionary(factory) -> None:
|
||||
value = factory()
|
||||
assert _round_trip(value) == value
|
||||
|
||||
|
||||
def test_unknown_fields_and_wrong_schema_are_rejected() -> None:
|
||||
stamp_payload = _stamp().to_dict()
|
||||
# Policy A: no same-version field additions - an unknown field names the
|
||||
# versioning rule instead of being silently dropped.
|
||||
with pytest.raises(ValueError, match="new STREAM_CONTRACT_VERSION"):
|
||||
FrameStamp.from_dict({**stamp_payload, "unexpected": 1})
|
||||
with pytest.raises(ValueError, match="schema"):
|
||||
FrameStamp.from_dict({**stamp_payload, "schema": "stream-contract/2"})
|
||||
with pytest.raises(ValueError, match="missing field"):
|
||||
FrameStamp.from_dict(
|
||||
{key: value for key, value in stamp_payload.items() if key != "sensor_id"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown field"):
|
||||
StreamEvent.from_dict(
|
||||
{"schema": STREAM_CONTRACT_VERSION, "kind": "x", "extra": 1}
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown field"):
|
||||
# A nested frame header follows the same policy.
|
||||
MeasurementFrame.from_dict(
|
||||
{
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
"stamp": {**stamp_payload, "unexpected": 1},
|
||||
"source_dt_s": None,
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="schema"):
|
||||
MeasurementFrame.from_dict(
|
||||
{
|
||||
"schema": "stream-contract/0",
|
||||
"stamp": stamp_payload,
|
||||
"source_dt_s": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_the_reader_lists_the_versions_it_accepts() -> None:
|
||||
"""Policy A: strict reading, and only explicitly listed versions are read."""
|
||||
assert SUPPORTED_CONTRACT_VERSIONS == (STREAM_CONTRACT_VERSION,)
|
||||
payload = _stamp().to_dict()
|
||||
# The listed version is the one this reader was built for and it reads.
|
||||
assert FrameStamp.from_dict(payload) == _stamp()
|
||||
|
||||
# A different version is a different field contract: it is rejected rather
|
||||
# than guessed at. Bumping STREAM_CONTRACT_VERSION and adding the new value
|
||||
# to SUPPORTED_CONTRACT_VERSIONS is what makes a new version readable.
|
||||
for schema in ("stream-contract/0", "stream-contract/2", "other/1"):
|
||||
with pytest.raises(ValueError, match="must be one of"):
|
||||
FrameStamp.from_dict({**payload, "schema": schema})
|
||||
with pytest.raises(ValueError, match="must be one of"):
|
||||
MeasurementFrame.from_dict(
|
||||
{"schema": schema, "stamp": payload, "source_dt_s": None}
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be one of"):
|
||||
StreamEvent.from_dict({"schema": schema, "kind": "x"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------- event extras
|
||||
def test_events_are_hashable_and_equal_events_share_a_hash() -> None:
|
||||
"""The frozen event is usable as a dict/set key; equality and hash agree."""
|
||||
first = StreamEvent(
|
||||
kind="reference_reset", stamp=_stamp(), payload={"from": 1, "to": 2}
|
||||
)
|
||||
second = StreamEvent(
|
||||
kind="reference_reset",
|
||||
stamp=_stamp(),
|
||||
payload={"to": 2, "from": 1},
|
||||
)
|
||||
assert first == second
|
||||
assert hash(first) == hash(second)
|
||||
# A JSON read-back is still the same value, so it must hash the same too.
|
||||
assert _round_trip(first) == first
|
||||
assert hash(_round_trip(first)) == hash(first)
|
||||
# The frozen payload is excluded from the hash, so it cannot make hashing
|
||||
# fail; events differing only in payload may still share a hash bucket.
|
||||
assert hash(StreamEvent(kind="source_gap", stamp=_stamp())) == hash(
|
||||
StreamEvent(kind="source_gap", stamp=_stamp(), payload={"dropped": 1})
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind",
|
||||
["NotSnake", "bad kind", "1leading", "with-dash", "_leading", ""],
|
||||
)
|
||||
def test_event_kind_rejects_names_outside_lower_snake(kind) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind=kind)
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent.from_dict({"schema": STREAM_CONTRACT_VERSION, "kind": kind})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind", ["a", "source_gap", "reference_reset", "zero2", "a_b_c_1"]
|
||||
)
|
||||
def test_event_kind_accepts_lower_snake_and_round_trips(kind) -> None:
|
||||
event = StreamEvent(kind=kind, payload={"x": 1})
|
||||
assert event.kind == kind
|
||||
assert _round_trip(event) == event
|
||||
Reference in New Issue
Block a user