feat(perception): add scoped stream control and bounded clock observations
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""Conditional clock intervals, never a symmetric-network offset assertion."""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.perception.streaming_clock import ClockMappingError, ClockProbe, ClockWindow
|
||||
|
||||
BASE = 10**16 # Deliberately above IEEE754 exact-integer range.
|
||||
|
||||
|
||||
def probe(number=1, *, outbound=1_000_000, inbound=2_000_000, offset=-(10**12)):
|
||||
sent = BASE + number * 100_000_000
|
||||
return ClockProbe(
|
||||
"mac",
|
||||
"worker",
|
||||
str(number),
|
||||
sent,
|
||||
sent + offset + outbound,
|
||||
sent + offset + outbound + 100_000,
|
||||
sent + outbound + inbound + 100_000,
|
||||
)
|
||||
|
||||
|
||||
def window(**kwargs):
|
||||
return ClockWindow("mac", "worker", rate_ppm=500, timestamp_error_ns=50_000, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offset", [-(10**12), 0, 10**12])
|
||||
@pytest.mark.parametrize("outbound,inbound", [(1, 9_000_000), (9_000_000, 1), (1, 1)])
|
||||
def test_contains_true_offset_without_symmetric_path_assumption(offset, outbound, inbound):
|
||||
sample = probe(offset=offset, outbound=outbound, inbound=inbound)
|
||||
mapping = window().add(sample)
|
||||
now = sample.local_receive_ns
|
||||
assert mapping.offset_at(now)[0] <= offset <= mapping.offset_at(now)[1]
|
||||
assert mapping.uncertainty_ns(now + 1_000_000) > mapping.uncertainty_ns(now)
|
||||
value = mapping.to_dict()
|
||||
assert value["measured_at_ns"] == str(now)
|
||||
assert not value["symmetric_network_assumed"]
|
||||
assert value["timestamp_error_budget_ns"] == "50000"
|
||||
|
||||
|
||||
def test_intersection_requires_evidence_and_drift_is_not_averaged_away():
|
||||
mapping = window()
|
||||
first = mapping.add(probe(outbound=1_000_000, inbound=20_000_000))
|
||||
with pytest.raises(ClockMappingError, match="budget"):
|
||||
first.require(first.measured_at_ns)
|
||||
second = mapping.add(probe(2, outbound=20_000_000, inbound=1_000_000))
|
||||
second.require(second.measured_at_ns)
|
||||
assert second.uncertainty_ns(second.measured_at_ns) < 2_000_000
|
||||
assert (
|
||||
second.offset_at(second.measured_at_ns)[0]
|
||||
< -(10**12)
|
||||
< second.offset_at(second.measured_at_ns)[1]
|
||||
)
|
||||
|
||||
|
||||
def test_expired_mapping_waits_and_new_observation_can_restore_it():
|
||||
mapping = window()
|
||||
first = mapping.add(probe())
|
||||
with pytest.raises(ClockMappingError, match="expired"):
|
||||
first.require(first.expires_at_ns)
|
||||
with pytest.raises(ClockMappingError, match="expired"):
|
||||
mapping.current(first.expires_at_ns)
|
||||
resumed = mapping.add(probe(30))
|
||||
assert resumed.samples == 1
|
||||
resumed.require(resumed.measured_at_ns)
|
||||
|
||||
|
||||
def test_contradictory_samples_quarantine_not_cherry_pick():
|
||||
mapping = window()
|
||||
mapping.add(probe())
|
||||
with pytest.raises(ClockMappingError, match="envelope"):
|
||||
mapping.add(probe(2, offset=0))
|
||||
with pytest.raises(ClockMappingError, match="quarantined"):
|
||||
mapping.add(probe(3))
|
||||
with pytest.raises(ClockMappingError):
|
||||
mapping.current(BASE + 3_000_000_000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fault", ["local", "remote", "nonce", "overlap"])
|
||||
def test_foreign_or_replayed_observation_cannot_refresh_mapping(fault):
|
||||
mapping = window()
|
||||
original = probe()
|
||||
mapping.add(original)
|
||||
second = probe(2)
|
||||
changes = {
|
||||
"local": {"local_clock_id": "other"},
|
||||
"remote": {"remote_clock_id": "rebooted"},
|
||||
"nonce": {"nonce": original.nonce},
|
||||
"overlap": {"local_send_ns": original.local_receive_ns},
|
||||
}
|
||||
with pytest.raises(ClockMappingError):
|
||||
mapping.add(replace(second, **changes[fault]))
|
||||
assert mapping.last_receive_ns == original.local_receive_ns
|
||||
|
||||
|
||||
def test_history_is_bounded_and_exact_values_survive():
|
||||
mapping = window(maximum_age_ns=5_000_000_000)
|
||||
for number in range(1, 35):
|
||||
bounds = mapping.add(probe(number))
|
||||
assert len(mapping.samples) == bounds.samples == 16
|
||||
assert bounds.to_dict()["measured_at_ns"] == str(probe(34).local_receive_ns)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 2**63, True, 1.0])
|
||||
def test_invalid_monotonic_timestamp_rejected(value):
|
||||
with pytest.raises(ClockMappingError):
|
||||
replace(probe(), local_send_ns=value)
|
||||
|
||||
|
||||
def test_deadline_remote_order_and_backward_reads():
|
||||
sample = probe()
|
||||
for changes in (
|
||||
{"local_receive_ns": sample.local_send_ns - 1},
|
||||
{"local_receive_ns": sample.local_send_ns + 2_000_000_001},
|
||||
{"remote_send_ns": sample.remote_receive_ns - 1},
|
||||
):
|
||||
with pytest.raises(ClockMappingError):
|
||||
replace(sample, **changes)
|
||||
mapping = window().add(sample)
|
||||
with pytest.raises(ClockMappingError):
|
||||
mapping.offset_at(mapping.measured_at_ns - 1)
|
||||
with pytest.raises(ClockMappingError):
|
||||
replace(mapping, offset_lower_ns=mapping.offset_upper_ns + 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rate,error,age",
|
||||
[(0, 1, 1), (1001, 1, 1), (True, 1, 1), (1, 0, 1), (1, 1_000_001, 1), (1, 1, 0)],
|
||||
)
|
||||
def test_controller_must_supply_bounded_envelope(rate, error, age):
|
||||
with pytest.raises(ClockMappingError):
|
||||
ClockWindow("mac", "worker", rate_ppm=rate, timestamp_error_ns=error, maximum_age_ns=age)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Small TLS clock/grant checks; no model workload or lease authority in Poll."""
|
||||
|
||||
# ruff: noqa: E402, F811 -- imported shared pytest TLS fixture.
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import replace
|
||||
from hashlib import sha256
|
||||
|
||||
import pytest
|
||||
|
||||
grpc = pytest.importorskip("grpc")
|
||||
|
||||
from test_perception_streaming_grpc import event, eventually, identity, tls # noqa: F401
|
||||
|
||||
from k1link.perception import streaming_wire as wire
|
||||
from k1link.perception.streaming_clock import ClockWindow
|
||||
from k1link.perception.streaming_continuity import ResumeEvidence
|
||||
from k1link.perception.streaming_control_grpc import (
|
||||
MAX_CONTROL,
|
||||
ControlTicket,
|
||||
StreamControlClient,
|
||||
StreamControlEndpoint,
|
||||
_offer,
|
||||
)
|
||||
from k1link.perception.streaming_grpc import GrpcStreamClient, GrpcStreamEndpoint, StreamAccess
|
||||
from k1link.perception.streaming_lifecycle import StreamingLifecycle
|
||||
from k1link.perception.streaming_queue import StreamMailbox
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def controlled(tmp_path, tls):
|
||||
run = StreamingLifecycle(
|
||||
identity(),
|
||||
tmp_path,
|
||||
StreamMailbox(),
|
||||
threading.Event(),
|
||||
recover_input=True,
|
||||
source_clock_ns=lambda: 1_000_000_000,
|
||||
)
|
||||
run.ready()
|
||||
endpoint = GrpcStreamEndpoint(
|
||||
run,
|
||||
lambda value: endpoint.publish(
|
||||
run.continuity.epoch, value.ingress_sequence, sha256(value.payload).digest()
|
||||
),
|
||||
lambda _: None,
|
||||
)
|
||||
pending = [endpoint.issue(identity(), "capture", 1)]
|
||||
ticket = ControlTicket(identity(), "b" * 64)
|
||||
control = StreamControlEndpoint(ticket, lambda: pending[0], clock_id="worker")
|
||||
server, port = await endpoint.serve(
|
||||
"localhost:0",
|
||||
certificate=tls[0],
|
||||
private_key=tls[1],
|
||||
control_handlers=(control.handler(),),
|
||||
)
|
||||
client = StreamControlClient(f"localhost:{port}", tls[0], ticket, clock_id="mac")
|
||||
try:
|
||||
yield run, endpoint, control, client, pending, port
|
||||
finally:
|
||||
await client.close()
|
||||
await server.stop(0)
|
||||
await eventually(lambda: endpoint.active is None)
|
||||
assert run.close()
|
||||
|
||||
|
||||
def test_control_delivery_coexists_with_stream_without_lease_or_model_authority(tmp_path, tls):
|
||||
async def check():
|
||||
async with controlled(tmp_path, tls) as (run, endpoint, control, client, pending, port):
|
||||
probe, access = await client.poll()
|
||||
assert access == pending[0]
|
||||
mapping = ClockWindow("mac", "worker", rate_ppm=500, timestamp_error_ns=50_000)
|
||||
assert mapping.add(probe).uncertainty_ns(probe.local_receive_ns) > 0
|
||||
first = access
|
||||
for phase in range(2):
|
||||
stream = GrpcStreamClient(f"localhost:{port}", tls[0], access)
|
||||
try:
|
||||
await stream.open()
|
||||
pending[0] = None
|
||||
await stream.send(event(phase + 1))
|
||||
assert await stream.receive() == (phase + 1, sha256(event().payload).digest())
|
||||
await asyncio.sleep(0.03)
|
||||
_, absent = await client.poll()
|
||||
assert absent is None and endpoint.active is not None
|
||||
finally:
|
||||
await stream.close()
|
||||
await eventually(lambda: endpoint.active is None)
|
||||
assert run.continuity.phase == "waiting"
|
||||
assert run.lease.start == identity()
|
||||
if phase == 0:
|
||||
run.renew(identity()) # Only independent trusted owner renews lease.
|
||||
epoch = run.begin_input(identity())
|
||||
run.resume_input(
|
||||
epoch, ResumeEvidence(*([1_000_000_000] * 4), True), lambda: None
|
||||
)
|
||||
pending[0] = endpoint.issue(epoch, "capture", 1)
|
||||
await asyncio.sleep(0.03)
|
||||
_, access = await client.poll()
|
||||
assert access.epoch != first.epoch and access.token != first.token
|
||||
assert control.accepted == 4 and run.lease.start.lease_generation == 1
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fault", ["secret", "binding", "duplicate", "oversize", "int64", "extra"])
|
||||
def test_bad_control_cannot_claim_data_or_change_owner(tmp_path, tls, fault):
|
||||
async def check():
|
||||
async with controlled(tmp_path, tls) as (run, endpoint, control, client, pending, _):
|
||||
metadata = client.ticket.metadata()
|
||||
request = {"nonce": "n", "local_clock_id": "mac", "local_send_ns": "1"}
|
||||
if fault == "secret":
|
||||
metadata = ControlTicket(identity(), "0" * 64).metadata()
|
||||
elif fault == "binding":
|
||||
metadata = ControlTicket(
|
||||
replace(identity(), source_id="other"), "b" * 64
|
||||
).metadata()
|
||||
elif fault == "duplicate":
|
||||
metadata = (*metadata, *metadata)
|
||||
elif fault == "int64":
|
||||
request["local_send_ns"] = str(2**63)
|
||||
elif fault == "extra":
|
||||
request["renew_lease"] = True
|
||||
raw = b"x" * (MAX_CONTROL + 1) if fault == "oversize" else wire.canonical(request)
|
||||
with pytest.raises(grpc.aio.AioRpcError):
|
||||
await client.call(raw, metadata=metadata, timeout=1)
|
||||
assert endpoint.active is None and endpoint.grant is not None
|
||||
assert run.lease.start == identity() and run.mailbox.bytes == 0
|
||||
assert control.accepted == 0
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
def test_control_rate_and_single_outstanding_are_bounded(tmp_path, tls):
|
||||
async def check():
|
||||
async with controlled(tmp_path, tls) as (_, _, control, client, _, _):
|
||||
control.clock_ns = lambda: 10**16
|
||||
await client.poll()
|
||||
with pytest.raises(grpc.aio.AioRpcError) as exc:
|
||||
await client.poll()
|
||||
assert exc.value.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
|
||||
client.polling = True
|
||||
with pytest.raises(ValueError, match="outstanding"):
|
||||
await client.poll()
|
||||
assert control.accepted == control.rejected == 1
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes",
|
||||
[
|
||||
{"source_id": "other"},
|
||||
{"clock_domain_id": "other"},
|
||||
{"input_mode": "recorded-source-paced"},
|
||||
{"lease_generation": 2},
|
||||
],
|
||||
)
|
||||
def test_offer_is_bound_to_every_activation_field(changes):
|
||||
access = StreamAccess(replace(identity(), **changes), "capture", 1, "a" * 64)
|
||||
with pytest.raises(ValueError):
|
||||
_offer(access, identity())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, 1, "x" * 64, "a" * 63])
|
||||
def test_invalid_capabilities_fail_closed_without_leaking_in_repr(value):
|
||||
with pytest.raises(ValueError):
|
||||
ControlTicket(identity(), value)
|
||||
assert "b" * 64 not in repr(ControlTicket(identity(), "b" * 64))
|
||||
|
||||
|
||||
def test_malicious_response_nonce_or_offer_rejected(tls):
|
||||
async def check():
|
||||
ticket = ControlTicket(identity(), "a" * 64)
|
||||
client = StreamControlClient("localhost:1", tls[0], ticket, clock_id="mac")
|
||||
for mode in ("nonce", "offer"):
|
||||
|
||||
async def fake(raw, mode=mode, **kwargs):
|
||||
request = wire.parse_header(bytearray(raw))
|
||||
return wire.canonical(
|
||||
{
|
||||
"schema_version": "missioncore.stream-control-poll/v1",
|
||||
"activation_binding": wire.binding(identity()),
|
||||
**request,
|
||||
"nonce": "wrong" if mode == "nonce" else request["nonce"],
|
||||
"remote_clock_id": "worker",
|
||||
"remote_receive_ns": str(time.monotonic_ns()),
|
||||
"remote_send_ns": str(time.monotonic_ns()),
|
||||
"grant": {
|
||||
"epoch": replace(identity(), source_id="foreign").to_dict(),
|
||||
"session_id": "capture",
|
||||
"session_generation": "1",
|
||||
"token": "a" * 64,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
client.call = fake
|
||||
with pytest.raises(ValueError):
|
||||
await client.poll()
|
||||
assert not client.polling
|
||||
await client.close()
|
||||
|
||||
asyncio.run(check())
|
||||
Reference in New Issue
Block a user