Skip to content

[Hackathon] linux-kernel: per-hop latency models for the in-memory transport#8

Open
mariagorskikh wants to merge 2 commits into
mainfrom
hackathon/linux-kernel-latency-transport
Open

[Hackathon] linux-kernel: per-hop latency models for the in-memory transport#8
mariagorskikh wants to merge 2 commits into
mainfrom
hackathon/linux-kernel-latency-transport

Conversation

@mariagorskikh

@mariagorskikh mariagorskikh commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Layer picked

Transport (layer 1) — reference plugin extension + simulator wiring.

Why

The README has a section titled "Determinism & what the clock does" that explicitly flags this gap:

The bundled in_memory transport delivers at time = now, so the virtual clock stays at 0.0 and mean_latency / duration will both be 0.0 in your trace... Latency numbers become meaningful only when ... you write a transport plugin that introduces per-hop delay.

For a testing tool that brags about "diff a trace against properties you care about," a transport that always reports zero latency is a sharp edge. NEST already exposes mean_latency as a built-in metric; it's just always 0.0. This PR fixes that without breaking anything.

Core idea

A tiny, composable latency-model layer wired into the existing in-memory transport. One thing, well.

  • Simulator(latency_model=...) — optional callable (rng, sender, receiver) -> delay_seconds. The simulator gets a dedicated, seed-derived _latency_rng so adding latency never perturbs the per-agent or failure RNG streams — existing traces stay byte-identical.
  • InMemoryTransport.send schedules deliveries at now + delay, clamped at zero so the clock can never go backwards.
  • New scenario field transport_config: (parsed by runner._build_latency_model_from_config) so users get realistic latency from YAML alone.

Six pure, composable models in nest_plugins_reference.transport.latency:

kind Parameters Use case
constant mean Every hop same.
uniform low, high Bounded jitter.
exponential mean Long-tail packet network.
normal mean, stddev, min_delay Tight RTT distribution.
pair_matrix matrix: {"a,b": delay, ...}, default Heterogeneous topology (intra-DC fast, inter-DC slow).
zero Explicit legacy default.

Plus with_jitter(base, jitter) to wrap any model in ±uniform jitter.

How to test

pip install -e packages/nest-core -e packages/nest-sdk -e packages/nest-plugins-reference -e packages/nest-cli -e packages/nest-scenarios -e packages/nest-mocks
pip install pytest pytest-asyncio hypothesis

# All 237 tests pass (193 baseline + 36 new + 8 runner-level).
pytest packages/nest-core/tests packages/nest-plugins-reference/tests -q

# End-to-end smoke: copy the marketplace YAML, append a latency block, run it.
cp scenarios/marketplace.yaml /tmp/m.yaml
cat >> /tmp/m.yaml <<EOF

transport_config:
  kind: exponential
  mean: 0.020
  jitter: 0.005
EOF
nest run /tmp/m.yaml -o /tmp/m.jsonl

# Now mean_latency is ~0.02 instead of 0.0; virtual clock advances.
python -c "
import json, statistics
ev=[json.loads(l) for l in open('/tmp/m.jsonl') if l.strip()]
sends={}; ds=[]
for e in ev:
    c=e.get('corr','')
    if not c: continue
    if e['kind']=='send': sends[c]=e['ts']
    elif e['kind']=='receive' and c in sends: ds.append(e['ts']-sends[c])
print('mean_latency:', round(statistics.fmean(ds),5))
print('clock_max   :', round(max(e['ts'] for e in ev),5))
"

Run it twice — the traces are byte-identical (md5sum confirms).

Key assumptions / design notes

  • Backward-compatible by construction. No transport_config ⇒ no latency model ⇒ time = now ⇒ existing traces are byte-for-byte unchanged. I verified the bundled marketplace baseline trace MD5-matches across a fresh run on this branch.
  • Determinism preserved. The latency RNG is derived from seed directly ((seed * 2654435761 + 0xA1A7C7) & ((1<<63)-1)), not from the master RNG sequence. Adding/removing the latency feature does not change the per-agent or failure-RNG streams for an existing scenario.
  • Clamp at zero. All models clamp the delay to max(0.0, ...) so the virtual clock can never move backwards — important for the normal model and for with_jitter.
  • Compatible with existing failure injection. Verified by test_latency_compatible_with_drop_rate: latency + message_drop_rate + Byzantine + partitions all coexist.
  • Discoverable from YAML, scriptable from Python. The factory accepts either transport_config: {kind: ..., mean: ...} at the top level or nested as transport_config: {latency: {kind: ..., ...}} — whichever reads better.

Future work

  • A latency_model could itself become a named plugin in nest.plugins.transport, so the registry-driven plugin path (currently shadowed by the simulator's hard-wired InMemoryTransport) gets exercised.
  • A latency_p50/latency_p99 metric alongside mean_latency — easy now that the data is real.
  • Drop / corruption rate per-pair (pair_matrix-style) to model lossy WAN links.
  • A replay transport that reads latencies from a captured production trace.

Persona

Linux kernel maintainer — networking, queues, scheduling. Small, sharp tools. Pure functions, no global state, one knob per job.

https://claude.ai/code/session_01C5j2D4MgCkPgsjSCqBVpWW


Generated by Claude Code

Summary by Sourcery

Introduce configurable per-hop latency modeling to the in-memory transport and wire it through the simulator and scenario runner while preserving backward-compatible zero-latency behavior.

New Features:

  • Add optional per-hop latency configuration to scenarios via a new transport_config field that drives a latency model for the in-memory transport.
  • Provide a latency_model hook on the Simulator and InMemoryTransport to delay message deliveries in virtual time using a dedicated RNG.
  • Implement a set of reusable latency models and a factory in nest_plugins_reference (constant, uniform, exponential, normal, pair_matrix, zero, plus optional jitter) that can be constructed from YAML specs.

Enhancements:

  • Ensure latency modeling preserves determinism and legacy zero-latency traces when no configuration is provided.
  • Extend documentation with a new section describing per-hop latency configuration and available models.
  • Add helper utilities in the runner to build latency models from transport_config, including support for nested latency blocks.

Tests:

  • Add simulator-level integration tests covering latency behavior, determinism, interaction with drop rate, and pair-matrix topologies.
  • Add unit tests for all latency model primitives and the factory construction logic.
  • Add end-to-end runner tests verifying YAML-driven latency configuration, backward compatibility, and deterministic traces.

The README documents that mean_latency and duration are always 0.0 with
the default in_memory transport because every hop delivers at
``time = now``.  This adds a small, focused latency-model layer so those
numbers actually mean something — without breaking determinism or
existing traces.

Surface
-------

* ``Simulator(latency_model=...)`` — optional callable
  ``(rng, sender, receiver) -> delay_seconds``.  The simulator now
  carries a dedicated, seed-derived ``_latency_rng`` so adding latency
  never perturbs per-agent or failure RNG streams.
* ``InMemoryTransport.send`` schedules deliveries at ``now + delay``.
  ``delay`` is clamped at 0 so the clock can never go backwards.
* ``ScenarioConfig.transport_config`` — YAML block parsed by
  ``runner._build_latency_model_from_config``.  Empty/absent means the
  legacy zero-latency behaviour: byte-identical traces with prior runs.

Models in ``nest_plugins_reference.transport.latency``
------------------------------------------------------

Pure, composable functions:

* ``constant_latency``
* ``uniform_latency``
* ``exponential_latency``  (long-tail packet-network model)
* ``normal_latency``       (clamped at ``min_delay``)
* ``pair_matrix_latency``  (heterogeneous topologies)
* ``with_jitter``          (compose ±jitter onto any base model)
* ``zero_latency``         (explicit no-op for clarity)
* ``make_latency_model``   (YAML-dict -> model)

Tests
-----

* 36 new tests covering: each model's bounds and approximate mean,
  factory parsing, jitter envelope, RNG determinism, simulator
  integration (clock advances, mean_latency matches config), runner
  end-to-end (YAML -> measurable latency in trace), compatibility with
  message_drop and partition logic.
* All 193 pre-existing tests still pass.  Baseline marketplace trace
  is byte-identical to a fresh main checkout.

Usage
-----

```yaml
transport_config:
  kind: exponential
  mean: 0.020
  jitter: 0.005
```

Same seed -> byte-identical trace, with realistic per-hop latency.
@sourcery-ai

sourcery-ai Bot commented May 26, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds configurable, deterministic per-hop latency modeling to the in-memory transport, plumbed from scenario YAML through ScenarioRunner into Simulator and InMemoryTransport, along with a suite of latency models in the reference plugins and comprehensive tests and docs.

Sequence diagram for latency model construction from YAML transport_config

sequenceDiagram
    actor User
    participant ScenarioRunner
    participant ScenarioConfig
    participant LatencyFactory as nest_plugins_reference.transport.latency
    participant Simulator

    User->>ScenarioRunner: run()
    ScenarioRunner->>ScenarioConfig: read transport_config
    ScenarioRunner->>ScenarioRunner: _build_latency_model()
    ScenarioRunner->>ScenarioRunner: _build_latency_model_from_config(transport_config)
    ScenarioRunner->>LatencyFactory: make_latency_model(spec)
    LatencyFactory-->>ScenarioRunner: LatencyModel
    ScenarioRunner-->>ScenarioRunner: latency_model
    ScenarioRunner->>Simulator: __init__(latency_model=latency_model, seed, ...)
    Simulator-->>ScenarioRunner: Simulator instance
Loading

Sequence diagram for InMemoryTransport send with per-hop latency model

sequenceDiagram
    participant Agent
    participant InMemoryTransport
    participant LatencyModel
    participant VirtualClock
    participant EventQueue

    Agent->>InMemoryTransport: send(to, payload, ...)
    InMemoryTransport->>VirtualClock: now
    VirtualClock-->>InMemoryTransport: now
    InMemoryTransport->>LatencyModel: latency_model(_latency_rng, _agent_id, to)
    LatencyModel-->>InMemoryTransport: delay_seconds
    InMemoryTransport->>EventQueue: push(Event(time=now + max(0.0, delay_seconds), kind=deliver, ...))
    EventQueue-->>InMemoryTransport: queued
    InMemoryTransport-->>Agent: send completed
Loading

File-Level Changes

Change Details Files
Wire an optional latency model from ScenarioConfig/ScenarioRunner into Simulator and InMemoryTransport so deliveries are scheduled at now+delay while preserving deterministic, zero-latency behavior by default.
  • Introduce ScenarioConfig.transport_config to hold optional per-hop latency settings.
  • Add helper _build_latency_model_from_config and ScenarioRunner._build_latency_model to turn transport_config into a latency_model or None, handling both top-level and nested latency blocks and missing reference plugins.
  • Extend Simulator to accept a latency_model, create a dedicated latency_rng derived directly from seed, and pass both into each InMemoryTransport instance.
  • Extend InMemoryTransport to accept latency_model and latency_rng, defaulting to zero-latency, and use latency_model to compute a non-negative delay added to the virtual clock when scheduling deliveries.
packages/nest-core/nest_core/scenario.py
packages/nest-core/nest_core/runner.py
packages/nest-core/nest_core/sim/simulator.py
packages/nest-core/nest_core/sim/transport.py
Implement a small library of pure latency models and a YAML-driven factory in the reference plugins, including support for pair-wise matrices and optional jitter, while keeping a zero-latency model as the explicit default.
  • Define LatencyModel type alias and primitive models: constant_latency, uniform_latency, exponential_latency, normal_latency, pair_matrix_latency, zero_latency.
  • Implement with_jitter wrapper that adds symmetric uniform jitter and clamps at zero.
  • Implement make_latency_model(spec) factory that interprets transport_config dicts, supports kinds constant
uniform
Document the new per-hop latency configuration and how to use it from YAML, updating the README to reflect the new behavior.
  • Replace prior note about in_memory transport always delivering at time=now with new guidance pointing to transport_config and ctx.schedule.
  • Add a dedicated "Per-hop latency" section showing a concrete YAML example and a table of supported latency model kinds and parameters.
  • Clarify that mean_latency and duration become meaningful as soon as transport_config is present.
README.md
Add tests covering latency model behavior, simulator wiring, YAML-level configuration, determinism, and compatibility with drop/partition logic.
  • Add unit tests for each primitive latency model and the make_latency_model factory, including parameter validation, distribution properties, pair_matrix parsing, jitter behavior, and determinism with fixed RNG seeds.
  • Add simulator-level integration tests that drive small ping/pong agents under various latency models to verify clock advancement, approximate means, heterogeneous pair-matrix behavior, determinism of traces, and coexistence with message_drop_rate.
  • Add runner-level tests that build minimal ScenarioConfig instances with and without transport_config to assert backward-compatible zero-latency behavior, non-zero constant latency via YAML, deterministic traces for stochastic models, acceptance of nested transport_config.latency, and correct behavior of _build_latency_model_from_config.
packages/nest-plugins-reference/tests/test_latency.py
packages/nest-core/tests/test_sim_latency.py
packages/nest-core/tests/test_runner_latency.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • The LatencyModel type alias is defined separately in both nest_core.sim.transport and nest_plugins_reference.transport.latency, while Simulator/runner use Any in places; consider centralising this alias in one module and importing it to avoid divergence between core and plugin interfaces.
  • The comments around the latency RNG usage are now misleading: InMemoryTransport.init says it reuses the simulator's failure RNG, and the LatencyModel docstring in transport.py says the simulator passes its failure RNG, but Simulator actually passes a dedicated _latency_rng; update these docstrings to reflect the current RNG wiring.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The LatencyModel type alias is defined separately in both nest_core.sim.transport and nest_plugins_reference.transport.latency, while Simulator/runner use Any in places; consider centralising this alias in one module and importing it to avoid divergence between core and plugin interfaces.
- The comments around the latency RNG usage are now misleading: InMemoryTransport.__init__ says it reuses the simulator's failure RNG, and the LatencyModel docstring in transport.py says the simulator passes its failure RNG, but Simulator actually passes a dedicated _latency_rng; update these docstrings to reflect the current RNG wiring.

## Individual Comments

### Comment 1
<location path="packages/nest-core/nest_core/runner.py" line_range="53-57" />
<code_context>
+    spec = raw_latency if isinstance(raw_latency, dict) else transport_config
+    if not spec:
+        return None
+    try:
+        from nest_plugins_reference.transport.latency import make_latency_model
+    except ImportError:  # pragma: no cover — only when reference plugins aren't installed
+        return None
+    return make_latency_model(spec)
+
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Silently ignoring ImportError for latency plugins may hide configuration mistakes.

When `nest_plugins_reference` isn’t installed, `_build_latency_model_from_config` returns `None`, so a config that explicitly sets a latency model ends up running with no latency and no signal that anything is wrong. It would be better to raise an explicit error or at least log a warning when `transport_config` requests a latency model but the reference plugin import fails, so misconfigurations don’t go unnoticed.
</issue_to_address>

### Comment 2
<location path="packages/nest-plugins-reference/nest_plugins_reference/transport/latency.py" line_range="262-266" />
<code_context>
+        )
+        raise ValueError(msg)
+
+    jitter = spec.get("jitter")
+    if jitter is not None and float(jitter) > 0:
+        base = with_jitter(base, float(jitter))
+
+    return base
+
+
</code_context>
<issue_to_address>
**issue:** Negative `jitter` values are silently ignored instead of raising as other helpers do.

Here `jitter` is only applied when `float(jitter) > 0`, so negative values are silently treated as “no jitter”. In contrast, `with_jitter` validates `jitter >= 0` and raises on invalid input. To keep behavior consistent and surface misconfigurations, consider rejecting negative `jitter` here as well (e.g., raise if `jitter < 0`).
</issue_to_address>

### Comment 3
<location path="packages/nest-plugins-reference/nest_plugins_reference/transport/latency.py" line_range="229-233" />
<code_context>
+        base = uniform_latency(float(spec["low"]), float(spec["high"]))
+    elif kind == "exponential":
+        base = exponential_latency(float(spec["mean"]))
+    elif kind == "normal":
+        base = normal_latency(
+            float(spec["mean"]),
+            float(spec["stddev"]),
+            min_delay=float(spec.get("min_delay", 0.0)),
+        )
+    elif kind == "pair_matrix":
</code_context>
<issue_to_address>
**suggestion:** Missing required parameters in `normal` latency spec cause a KeyError rather than a clearer configuration error.

Here we index `spec["mean"]` and `spec["stddev"]` directly, so a missing field results in a raw `KeyError` instead of a clear configuration error. Consider either raising a `ValueError` with a descriptive message when required fields are absent, or validating required keys for each `kind` before constructing the latency function.

Suggested implementation:

```python
    elif kind == "normal":
        missing = [key for key in ("mean", "stddev") if key not in spec]
        if missing:
            missing_str = ", ".join(sorted(missing))
            msg = f"normal latency spec missing required field(s): {missing_str}"
            raise ValueError(msg)
        base = normal_latency(
            float(spec["mean"]),
            float(spec["stddev"]),
            min_delay=float(spec.get("min_delay", 0.0)),
        )

```

If you want consistent behavior across all latency kinds, you can apply the same pattern to other branches:

- `"constant"`: require `"mean"`.
- `"uniform"`: require `"low"` and `"high"`.
- `"exponential"`: require `"mean"`.

For each branch, add a similar `missing = [...]` check before indexing into `spec` and raise a descriptive `ValueError` listing the missing keys.
</issue_to_address>

### Comment 4
<location path="packages/nest-core/tests/test_sim_latency.py" line_range="95-104" />
<code_context>
+
+
+# ---------------------------------------------------------------------------
+# Constant latency: every hop adds exactly the configured delay
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_constant_latency_advances_clock(tmp_path: Path) -> None:
+    from nest_plugins_reference.transport.latency import constant_latency
+
+    trace = tmp_path / "constant.jsonl"
+    sim = Simulator(seed=1, trace_path=trace, latency_model=constant_latency(0.01))
+    sim.add_agent(AgentId("p"), _Pinger(AgentId("e")))
+    sim.add_agent(AgentId("e"), _Echoer())
+
+    await sim.run(max_ticks=200)
+
+    events = _read_trace(trace)
+    mean = _mean_latency(events)
+    # ping -> arrives at 0.01, pong -> arrives at 0.02.  Mean of the two
+    # send-to-receive deltas is exactly 0.01.
+    assert mean == pytest.approx(0.01, rel=1e-9)
+    # The virtual clock advanced past zero.
+    assert sim.clock.now >= 0.01
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a transport-level test that a negative latency from a custom model is clamped and does not move the clock backwards

The existing clamping in `InMemoryTransport.send` prevents negative delays, but a custom `latency_model` could still return them. Please add a test that injects a custom model returning a negative delay into `Simulator`, sends a message, and asserts that event timestamps never precede `clock.now` and that the clock is monotonically non-decreasing. This will lock in the contract that latency cannot move time backwards, independent of the built-in models.

Suggested implementation:

```python
    events = _read_trace(trace)
    assert _mean_latency(events) == 0.0
    assert sim.clock.now == 0.0


# ---------------------------------------------------------------------------
# Negative latency: custom model is clamped and does not move time backwards
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_negative_latency_is_clamped_and_monotone(tmp_path: Path) -> None:
    # Custom latency model that always returns a negative delay.
    # The InMemoryTransport is expected to clamp this to a non-negative delay,
    # so that time never moves backwards.
    def negative_latency(*_args: object, **_kwargs: object) -> float:
        return -0.5

    trace = tmp_path / "negative_latency.jsonl"
    sim = Simulator(seed=1, trace_path=trace, latency_model=negative_latency)
    sim.add_agent(AgentId("p"), _Pinger(AgentId("e")))
    sim.add_agent(AgentId("e"), _Echoer())

    await sim.run(max_ticks=200)

    events = _read_trace(trace)

    # Extract event timestamps from the trace. We expect them to be
    # monotonically non-decreasing and never move time backwards.
    timestamps = [event["time"] for event in events]

    # No event should be scheduled before time zero.
    assert not timestamps or min(timestamps) >= 0.0

    # Event timestamps must be monotonically non-decreasing.
    assert all(
        later >= earlier for earlier, later in zip(timestamps, timestamps[1:])
    )

    # The simulator clock is monotonically non-decreasing and should end
    # at or after the last event timestamp.
    if timestamps:
        assert sim.clock.now >= timestamps[-1]
    assert sim.clock.now >= 0.0


# ---------------------------------------------------------------------------

These tests live in nest-core (not nest-plugins-reference) because they

```

I assumed each trace event has a numeric `"time"` field representing the event timestamp, since that’s the most common pattern and matches the semantics needed for these assertions. If the actual key used in your trace format differs (for example `"ts"`, `"timestamp"`, or nested under another key), please update `timestamps = [event["time"] for event in events]` accordingly, or adjust `_read_trace` to expose a uniform `"time"` field.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +53 to +57
try:
from nest_plugins_reference.transport.latency import make_latency_model
except ImportError: # pragma: no cover — only when reference plugins aren't installed
return None
return make_latency_model(spec)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Silently ignoring ImportError for latency plugins may hide configuration mistakes.

When nest_plugins_reference isn’t installed, _build_latency_model_from_config returns None, so a config that explicitly sets a latency model ends up running with no latency and no signal that anything is wrong. It would be better to raise an explicit error or at least log a warning when transport_config requests a latency model but the reference plugin import fails, so misconfigurations don’t go unnoticed.

Comment on lines +262 to +266
jitter = spec.get("jitter")
if jitter is not None and float(jitter) > 0:
base = with_jitter(base, float(jitter))

return base

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Negative jitter values are silently ignored instead of raising as other helpers do.

Here jitter is only applied when float(jitter) > 0, so negative values are silently treated as “no jitter”. In contrast, with_jitter validates jitter >= 0 and raises on invalid input. To keep behavior consistent and surface misconfigurations, consider rejecting negative jitter here as well (e.g., raise if jitter < 0).

Comment on lines +229 to +233
elif kind == "normal":
base = normal_latency(
float(spec["mean"]),
float(spec["stddev"]),
min_delay=float(spec.get("min_delay", 0.0)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Missing required parameters in normal latency spec cause a KeyError rather than a clearer configuration error.

Here we index spec["mean"] and spec["stddev"] directly, so a missing field results in a raw KeyError instead of a clear configuration error. Consider either raising a ValueError with a descriptive message when required fields are absent, or validating required keys for each kind before constructing the latency function.

Suggested implementation:

    elif kind == "normal":
        missing = [key for key in ("mean", "stddev") if key not in spec]
        if missing:
            missing_str = ", ".join(sorted(missing))
            msg = f"normal latency spec missing required field(s): {missing_str}"
            raise ValueError(msg)
        base = normal_latency(
            float(spec["mean"]),
            float(spec["stddev"]),
            min_delay=float(spec.get("min_delay", 0.0)),
        )

If you want consistent behavior across all latency kinds, you can apply the same pattern to other branches:

  • "constant": require "mean".
  • "uniform": require "low" and "high".
  • "exponential": require "mean".

For each branch, add a similar missing = [...] check before indexing into spec and raise a descriptive ValueError listing the missing keys.

Comment on lines +95 to +104
# Constant latency: every hop adds exactly the configured delay
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_constant_latency_advances_clock(tmp_path: Path) -> None:
from nest_plugins_reference.transport.latency import constant_latency

trace = tmp_path / "constant.jsonl"
sim = Simulator(seed=1, trace_path=trace, latency_model=constant_latency(0.01))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a transport-level test that a negative latency from a custom model is clamped and does not move the clock backwards

The existing clamping in InMemoryTransport.send prevents negative delays, but a custom latency_model could still return them. Please add a test that injects a custom model returning a negative delay into Simulator, sends a message, and asserts that event timestamps never precede clock.now and that the clock is monotonically non-decreasing. This will lock in the contract that latency cannot move time backwards, independent of the built-in models.

Suggested implementation:

    events = _read_trace(trace)
    assert _mean_latency(events) == 0.0
    assert sim.clock.now == 0.0


# ---------------------------------------------------------------------------
# Negative latency: custom model is clamped and does not move time backwards
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_negative_latency_is_clamped_and_monotone(tmp_path: Path) -> None:
    # Custom latency model that always returns a negative delay.
    # The InMemoryTransport is expected to clamp this to a non-negative delay,
    # so that time never moves backwards.
    def negative_latency(*_args: object, **_kwargs: object) -> float:
        return -0.5

    trace = tmp_path / "negative_latency.jsonl"
    sim = Simulator(seed=1, trace_path=trace, latency_model=negative_latency)
    sim.add_agent(AgentId("p"), _Pinger(AgentId("e")))
    sim.add_agent(AgentId("e"), _Echoer())

    await sim.run(max_ticks=200)

    events = _read_trace(trace)

    # Extract event timestamps from the trace. We expect them to be
    # monotonically non-decreasing and never move time backwards.
    timestamps = [event["time"] for event in events]

    # No event should be scheduled before time zero.
    assert not timestamps or min(timestamps) >= 0.0

    # Event timestamps must be monotonically non-decreasing.
    assert all(
        later >= earlier for earlier, later in zip(timestamps, timestamps[1:])
    )

    # The simulator clock is monotonically non-decreasing and should end
    # at or after the last event timestamp.
    if timestamps:
        assert sim.clock.now >= timestamps[-1]
    assert sim.clock.now >= 0.0


# ---------------------------------------------------------------------------

These tests live in nest-core (not nest-plugins-reference) because they

I assumed each trace event has a numeric "time" field representing the event timestamp, since that’s the most common pattern and matches the semantics needed for these assertions. If the actual key used in your trace format differs (for example "ts", "timestamp", or nested under another key), please update timestamps = [event["time"] for event in events] accordingly, or adjust _read_trace to expose a uniform "time" field.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants