From 398db99635ad5e82893ebe6587c77e72d9f9c58e Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 15:09:45 -0500 Subject: [PATCH 01/28] cortex init --- .github/workflows/lint.yml | 27 +++ README.md | 389 ++++++++++++++++++++++++++++++ benchmarks/__init__.py | 1 + benchmarks/bench_all.py | 331 +++++++++++++++++++++++++ benchmarks/bench_latency.py | 318 ++++++++++++++++++++++++ benchmarks/bench_throughput.py | 338 ++++++++++++++++++++++++++ examples/__init__.py | 1 + examples/multi_node_system.py | 241 ++++++++++++++++++ examples/publisher_dict.py | 111 +++++++++ examples/publisher_numpy.py | 87 +++++++ examples/publisher_tensor.py | 87 +++++++ examples/subscriber_dict.py | 100 ++++++++ examples/subscriber_numpy.py | 79 ++++++ examples/subscriber_tensor.py | 89 +++++++ pyproject.toml | 69 ++++++ src/cortex/__init__.py | 41 ++++ src/cortex/core/__init__.py | 18 ++ src/cortex/core/executor.py | 202 ++++++++++++++++ src/cortex/core/node.py | 326 +++++++++++++++++++++++++ src/cortex/core/publisher.py | 243 +++++++++++++++++++ src/cortex/core/subscriber.py | 304 +++++++++++++++++++++++ src/cortex/discovery/__init__.py | 14 ++ src/cortex/discovery/client.py | 285 ++++++++++++++++++++++ src/cortex/discovery/daemon.py | 380 +++++++++++++++++++++++++++++ src/cortex/discovery/protocol.py | 130 ++++++++++ src/cortex/messages/__init__.py | 22 ++ src/cortex/messages/base.py | 189 +++++++++++++++ src/cortex/messages/standard.py | 224 +++++++++++++++++ src/cortex/utils/__init__.py | 6 + src/cortex/utils/hashing.py | 76 ++++++ src/cortex/utils/serialization.py | 317 ++++++++++++++++++++++++ tests/__init__.py | 1 + tests/conftest.py | 96 ++++++++ tests/test_discovery.py | 221 +++++++++++++++++ tests/test_hashing.py | 107 ++++++++ tests/test_messages.py | 235 ++++++++++++++++++ tests/test_node.py | 251 +++++++++++++++++++ tests/test_pubsub.py | 386 +++++++++++++++++++++++++++++ tests/test_serialization.py | 251 +++++++++++++++++++ 39 files changed, 6593 insertions(+) create mode 100644 .github/workflows/lint.yml create mode 100644 README.md create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/bench_all.py create mode 100644 benchmarks/bench_latency.py create mode 100644 benchmarks/bench_throughput.py create mode 100644 examples/__init__.py create mode 100644 examples/multi_node_system.py create mode 100644 examples/publisher_dict.py create mode 100644 examples/publisher_numpy.py create mode 100644 examples/publisher_tensor.py create mode 100644 examples/subscriber_dict.py create mode 100644 examples/subscriber_numpy.py create mode 100644 examples/subscriber_tensor.py create mode 100644 pyproject.toml create mode 100644 src/cortex/__init__.py create mode 100644 src/cortex/core/__init__.py create mode 100644 src/cortex/core/executor.py create mode 100644 src/cortex/core/node.py create mode 100644 src/cortex/core/publisher.py create mode 100644 src/cortex/core/subscriber.py create mode 100644 src/cortex/discovery/__init__.py create mode 100644 src/cortex/discovery/client.py create mode 100644 src/cortex/discovery/daemon.py create mode 100644 src/cortex/discovery/protocol.py create mode 100644 src/cortex/messages/__init__.py create mode 100644 src/cortex/messages/base.py create mode 100644 src/cortex/messages/standard.py create mode 100644 src/cortex/utils/__init__.py create mode 100644 src/cortex/utils/hashing.py create mode 100644 src/cortex/utils/serialization.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_discovery.py create mode 100644 tests/test_hashing.py create mode 100644 tests/test_messages.py create mode 100644 tests/test_node.py create mode 100644 tests/test_pubsub.py create mode 100644 tests/test_serialization.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5f5d4bd --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install ruff + run: pip install ruff + + - name: Run ruff check + run: ruff check . + + - name: Run ruff format check + run: ruff format --check . diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6b21c6 --- /dev/null +++ b/README.md @@ -0,0 +1,389 @@ +# Cortex + +A lightweight framework for inter-process communication using ZeroMQ. + +## Overview + +Cortex provides a simple yet powerful way to build distributed systems in Python. It features: + +- **Publisher/Subscriber pattern** for decoupled communication +- **Discovery service** for automatic topic resolution +- **IPC transport** using ZeroMQ for low-latency local communication +- **64-bit fingerprint hashing** for fast message type identification +- **Native support** for NumPy arrays, PyTorch tensors, and Python dictionaries +- **Simple API** with Node, Publisher, Subscriber, and Executor abstractions + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Discovery Daemon │ +│ ipc:///tmp/cortex_discovery │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Topic Registry │ │ +│ │ /camera/image -> ipc:///tmp/cortex/topics/camera_... │ │ +│ │ /robot/state -> ipc:///tmp/cortex/topics/robot_... │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ ▲ + │ REQ/REP │ REQ/REP + │ (register, lookup) │ (lookup) + │ │ +┌────────┴────────┐ ┌─────────┴────────┐ +│ Publisher Node │ │ Subscriber Node │ +│ │ │ │ +│ ┌───────────┐ │ PUB/SUB (IPC) │ ┌───────────┐ │ +│ │ Publisher ├──┼─────────────────────┼──► Subscriber│ │ +│ └───────────┘ │ │ └───────────┘ │ +└─────────────────┘ └──────────────────┘ +``` + +## Installation + +```bash +# Clone the repository +git clone https://github.com/sudoRicheek/cortex.git +cd cortex + +# Install in development mode +pip install -e ".[dev]" + +# With PyTorch support +pip install -e ".[all]" +``` + +## Quick Start + +### 1. Start the Discovery Daemon + +The discovery daemon must be running for publishers and subscribers to find each other: + +```bash +# Start the discovery daemon +python -m cortex.discovery.daemon + +# Or use the installed command +cortex-discovery +``` + +### 2. Create a Publisher + +```python +import numpy as np +from cortex import Node, ArrayMessage + +# Create a node +node = Node(name="sensor_node") + +# Create a publisher +pub = node.create_publisher( + topic_name="/sensor/data", + message_type=ArrayMessage, +) + +# Publish messages +data = np.random.randn(100, 100).astype(np.float32) +pub.publish(ArrayMessage(data=data, name="frame_0")) +``` + +### 3. Create a Subscriber + +```python +from cortex import Node, ArrayMessage +from cortex.messages.base import MessageHeader + +def on_data_received(msg: ArrayMessage, header: MessageHeader): + print(f"Received: {msg.name}, shape={msg.data.shape}") + +# Create a node +node = Node(name="processor_node") + +# Create a subscriber +sub = node.create_subscriber( + topic_name="/sensor/data", + message_type=ArrayMessage, + callback=on_data_received, +) + +# Spin to process callbacks +node.spin() +``` + +## Message Types + +### Built-in Messages + +Cortex provides several built-in message types: + +```python +from cortex.messages.standard import ( + StringMessage, # Simple strings + IntMessage, # Integers + FloatMessage, # Floating point numbers + DictMessage, # Nested dictionaries + ArrayMessage, # NumPy arrays + TensorMessage, # PyTorch tensors + ImageMessage, # Image data + PointCloudMessage,# 3D point clouds + PoseMessage, # 6DOF poses +) +``` + +### Custom Messages + +Define your own messages using Python dataclasses: + +```python +from dataclasses import dataclass +import numpy as np +from cortex.messages.base import Message + +@dataclass +class RobotState(Message): + timestamp: float + position: np.ndarray # [x, y, z] + velocity: np.ndarray # [vx, vy, vz] + joint_angles: np.ndarray + is_moving: bool +``` + +Custom messages are automatically: +- Registered with the type system +- Assigned a 64-bit fingerprint for fast identification +- Serialized/deserialized efficiently + +## Working with Different Data Types + +### NumPy Arrays + +```python +from cortex import ArrayMessage +import numpy as np + +# Create array message +arr = np.random.randn(480, 640, 3).astype(np.float32) +msg = ArrayMessage(data=arr, name="rgb_image", frame_id="camera") + +# Publish +pub.publish(msg) +``` + +### PyTorch Tensors + +```python +from cortex import TensorMessage +import torch + +# Create tensor message +tensor = torch.randn(4, 256, 7, 7) +msg = TensorMessage(data=tensor, name="features") + +# Tensors are automatically moved to CPU for serialization +# Device information is preserved +pub.publish(msg) +``` + +### Dictionaries (Nested Structures) + +```python +from cortex import DictMessage +import numpy as np + +# Complex nested data +state = { + "timestamp": 1234567890.0, + "pose": { + "position": {"x": 1.0, "y": 2.0, "z": 0.0}, + "orientation": {"x": 0, "y": 0, "z": 0, "w": 1}, + }, + "joint_positions": np.array([0.1, 0.2, 0.3]), + "status": {"is_moving": True, "battery": 85.5}, +} + +msg = DictMessage(data=state) +pub.publish(msg) +``` + +## Node API + +### Creating Publishers and Subscribers + +```python +from cortex import Node + +node = Node(name="my_node") + +# Publisher +pub = node.create_publisher("/topic", MessageType, queue_size=10) + +# Subscriber with callback +sub = node.create_subscriber( + "/topic", + MessageType, + callback=my_callback, + wait_for_topic=True, # Wait for publisher to appear + topic_timeout=30.0, # Timeout for waiting +) +``` + +### Using Timers + +```python +def periodic_task(): + print("Timer fired!") + +# Create a timer that fires every 100ms +node.create_timer(0.1, periodic_task) + +# Spin to process timers and callbacks +node.spin() +``` + +### Using the Executor + +For multi-node systems: + +```python +from cortex.core.executor import SingleThreadedExecutor, MultiThreadedExecutor + +# Single-threaded execution +executor = SingleThreadedExecutor() +executor.add_node(node1) +executor.add_node(node2) +executor.spin() + +# Multi-threaded (each node in its own thread) +executor = MultiThreadedExecutor(num_threads=4) +executor.add_node(node1) +executor.add_node(node2) +executor.spin() +``` + +## Discovery Service + +### Using the Discovery Client Directly + +```python +from cortex.discovery import DiscoveryClient, TopicInfo + +client = DiscoveryClient() + +# List all topics +topics = client.list_topics() +for topic in topics: + print(f"{topic.name} -> {topic.address}") + +# Wait for a specific topic +topic_info = client.wait_for_topic("/camera/image", timeout=30.0) + +# Manual topic registration +info = TopicInfo( + name="/my/topic", + address="ipc:///tmp/my_socket", + message_type="MyMessage", + fingerprint=12345, + publisher_node="my_node" +) +client.register_topic(info) +``` + +## Message Fingerprinting + +Each message type has a unique 64-bit fingerprint computed from: +- The fully qualified class name +- Field names and types + +This allows fast message identification without parsing: + +```python +from cortex.messages.base import Message +from cortex.utils.hashing import compute_fingerprint + +@dataclass +class MyMessage(Message): + value: int + +# Get the fingerprint +fp = MyMessage.fingerprint() # e.g., 0x1234567890ABCDEF + +# Fingerprint is sent with every message for type verification +``` + +## Examples + +See the `examples/` directory for complete examples: + +- `publisher_numpy.py` / `subscriber_numpy.py` - NumPy array transfer +- `publisher_dict.py` / `subscriber_dict.py` - Dictionary messages +- `publisher_tensor.py` / `subscriber_tensor.py` - PyTorch tensor transfer +- `multi_node_system.py` - Complete multi-node system example + +Run examples: + +```bash +# Terminal 1 +python -m cortex.discovery.daemon + +# Terminal 2 +python examples/publisher_numpy.py + +# Terminal 3 +python examples/subscriber_numpy.py +``` + +## Testing + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=cortex + +# Run specific test file +pytest tests/test_messages.py -v +``` + +## Project Structure + +``` +cortex/ +├── src/cortex/ +│ ├── __init__.py # Package exports +│ ├── core/ +│ │ ├── node.py # Node abstraction +│ │ ├── publisher.py # Publisher implementation +│ │ ├── subscriber.py # Subscriber implementation +│ │ └── executor.py # Executor for multi-node +│ ├── discovery/ +│ │ ├── daemon.py # Discovery daemon +│ │ ├── client.py # Discovery client +│ │ └── protocol.py # Protocol definitions +│ ├── messages/ +│ │ ├── base.py # Base message class +│ │ └── standard.py # Standard message types +│ └── utils/ +│ ├── hashing.py # Fingerprint computation +│ └── serialization.py # Data serialization +├── tests/ # Unit tests +├── examples/ # Example applications +└── pyproject.toml # Package configuration +``` + +## Performance + +Cortex is designed for lightweight, high-frequency communication: + +- **IPC transport**: ZeroMQ IPC sockets for minimal latency +- **Zero-copy where possible**: NumPy arrays use efficient byte views +- **Fingerprint-based dispatch**: O(1) message type lookup +- **Minimal overhead**: Simple binary protocol without XML/JSON parsing + +Typical latencies on a modern system: +- Small messages (< 1KB): < 100 µs +- Large arrays (1 MB): < 5 ms + +## License + +Apache License 2.0 - see the [LICENSE](LICENSE) file for details. diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..6b3e41d --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Benchmarks for Cortex framework.""" diff --git a/benchmarks/bench_all.py b/benchmarks/bench_all.py new file mode 100644 index 0000000..fe38b27 --- /dev/null +++ b/benchmarks/bench_all.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +""" +Comprehensive benchmark suite for Cortex. + +Runs all benchmarks and generates a summary report. +""" + +import argparse +import json + +# Add parent to path for imports +import sys +import time +from datetime import datetime +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from bench_latency import run_latency_benchmark +from bench_throughput import run_throughput_benchmark + + +def run_all_benchmarks() -> dict: + """Run the complete benchmark suite.""" + + results = { + "timestamp": datetime.now().isoformat(), + "system_info": get_system_info(), + "benchmarks": {}, + } + + print("\n" + "=" * 80) + print("CORTEX BENCHMARK SUITE") + print("=" * 80) + + # 1. Latency benchmarks + print("\n[1/4] Running latency benchmarks...") + + latency_configs = [ + { + "num_messages": 1000, + "payload_size": 64, + "rate_hz": 1000, + "name": "small_payload", + }, + { + "num_messages": 1000, + "payload_size": 1024, + "rate_hz": 1000, + "name": "medium_payload", + }, + { + "num_messages": 500, + "payload_size": 65536, + "rate_hz": 500, + "name": "large_payload", + }, + {"num_messages": 5000, "payload_size": 256, "rate_hz": 0, "name": "max_rate"}, + ] + + results["benchmarks"]["latency"] = {} + for config in latency_configs: + name = config.pop("name") + print(f" - {name}...", end=" ", flush=True) + try: + result = run_latency_benchmark(**config) + results["benchmarks"]["latency"][name] = result + if "error" not in result: + print( + f"mean={result['latency_mean_us']:.1f}µs, p99={result['latency_p99_us']:.1f}µs" + ) + else: + print("ERROR") + except Exception as e: + print(f"FAILED: {e}") + results["benchmarks"]["latency"][name] = {"error": str(e)} + + # 2. Throughput benchmarks + print("\n[2/4] Running throughput benchmarks...") + + throughput_configs = [ + { + "num_messages": 10000, + "array_shape": (10,), + "dtype": "float32", + "name": "tiny_array", + }, + { + "num_messages": 5000, + "array_shape": (100, 100), + "dtype": "float32", + "name": "small_array", + }, + { + "num_messages": 1000, + "array_shape": (512, 512), + "dtype": "float32", + "name": "medium_array", + }, + { + "num_messages": 200, + "array_shape": (1024, 1024), + "dtype": "float32", + "name": "large_array", + }, + ] + + results["benchmarks"]["throughput"] = {} + for config in throughput_configs: + name = config.pop("name") + print(f" - {name}...", end=" ", flush=True) + try: + result = run_throughput_benchmark(**config) + results["benchmarks"]["throughput"][name] = result + if "error" not in result: + print( + f"{result['throughput_msg_per_s']:.0f} msg/s, {result['throughput_mb_per_s']:.1f} MB/s" + ) + else: + print("ERROR") + except Exception as e: + print(f"FAILED: {e}") + results["benchmarks"]["throughput"][name] = {"error": str(e)} + + # 3. Image-like data benchmarks + print("\n[3/4] Running image data benchmarks...") + + image_configs = [ + { + "num_messages": 1000, + "array_shape": (480, 640, 3), + "dtype": "uint8", + "name": "vga_rgb", + }, + { + "num_messages": 500, + "array_shape": (720, 1280, 3), + "dtype": "uint8", + "name": "720p_rgb", + }, + { + "num_messages": 200, + "array_shape": (1080, 1920, 3), + "dtype": "uint8", + "name": "1080p_rgb", + }, + ] + + results["benchmarks"]["images"] = {} + for config in image_configs: + name = config.pop("name") + print(f" - {name}...", end=" ", flush=True) + try: + result = run_throughput_benchmark(**config) + results["benchmarks"]["images"][name] = result + if "error" not in result: + fps = result["throughput_msg_per_s"] + mbps = result["throughput_mb_per_s"] + print(f"{fps:.1f} fps, {mbps:.1f} MB/s") + else: + print("ERROR") + except Exception as e: + print(f"FAILED: {e}") + results["benchmarks"]["images"][name] = {"error": str(e)} + + # 4. Serialization overhead + print("\n[4/4] Measuring serialization overhead...") + results["benchmarks"]["serialization"] = measure_serialization_overhead() + + return results + + +def get_system_info() -> dict: + """Get system information.""" + import platform + + return { + "platform": platform.system(), + "platform_release": platform.release(), + "processor": platform.processor(), + "python_version": platform.python_version(), + "numpy_version": np.__version__, + } + + +def measure_serialization_overhead() -> dict: + """Measure serialization/deserialization overhead.""" + from cortex.utils.serialization import deserialize, serialize + + results = {} + + test_cases = [ + ("1KB_array", np.random.randn(256).astype(np.float32)), + ("100KB_array", np.random.randn(256, 100).astype(np.float32)), + ("1MB_array", np.random.randn(512, 512).astype(np.float32)), + ("4MB_array", np.random.randn(1024, 1024).astype(np.float32)), + ] + + for name, data in test_cases: + # Warm up + for _ in range(10): + serialized = serialize(data) + deserialize(serialized) + + # Benchmark serialization + iterations = 100 + + start = time.perf_counter() + for _ in range(iterations): + serialized = serialize(data) + serialize_time = (time.perf_counter() - start) / iterations * 1000 # ms + + # Benchmark deserialization + start = time.perf_counter() + for _ in range(iterations): + deserialize(serialized) + deserialize_time = (time.perf_counter() - start) / iterations * 1000 # ms + + data_size_kb = data.nbytes / 1024 + + results[name] = { + "data_size_kb": data_size_kb, + "serialize_ms": serialize_time, + "deserialize_ms": deserialize_time, + "total_ms": serialize_time + deserialize_time, + "serialize_throughput_mb_s": (data_size_kb / 1024) + / (serialize_time / 1000), + "deserialize_throughput_mb_s": (data_size_kb / 1024) + / (deserialize_time / 1000), + } + + print( + f" - {name}: serialize={serialize_time:.3f}ms, deserialize={deserialize_time:.3f}ms" + ) + + return results + + +def print_summary(results: dict) -> None: + """Print a summary of all benchmark results.""" + + print("\n" + "=" * 80) + print("BENCHMARK SUMMARY") + print("=" * 80) + + # Latency summary + print("\n📊 LATENCY (microseconds)") + print("-" * 60) + print(f"{'Test':<20} {'Mean':>10} {'P50':>10} {'P99':>10} {'Max':>10}") + print("-" * 60) + + for name, data in results["benchmarks"].get("latency", {}).items(): + if "error" not in data: + print( + f"{name:<20} {data['latency_mean_us']:>10.1f} " + f"{data['latency_p50_us']:>10.1f} " + f"{data['latency_p99_us']:>10.1f} " + f"{data['latency_max_us']:>10.1f}" + ) + + # Throughput summary + print("\n📊 THROUGHPUT") + print("-" * 60) + print(f"{'Test':<20} {'Msg/s':>12} {'MB/s':>10} {'Loss %':>10}") + print("-" * 60) + + for name, data in results["benchmarks"].get("throughput", {}).items(): + if "error" not in data: + print( + f"{name:<20} {data['throughput_msg_per_s']:>12,.0f} " + f"{data['throughput_mb_per_s']:>10.1f} " + f"{data['loss_rate_percent']:>10.2f}" + ) + + # Image throughput + print("\n📊 IMAGE DATA (frames per second)") + print("-" * 60) + print(f"{'Resolution':<20} {'FPS':>10} {'MB/s':>10} {'Loss %':>10}") + print("-" * 60) + + for name, data in results["benchmarks"].get("images", {}).items(): + if "error" not in data: + print( + f"{name:<20} {data['throughput_msg_per_s']:>10.1f} " + f"{data['throughput_mb_per_s']:>10.1f} " + f"{data['loss_rate_percent']:>10.2f}" + ) + + # Serialization overhead + print("\n📊 SERIALIZATION OVERHEAD") + print("-" * 60) + print(f"{'Size':<20} {'Serialize':>12} {'Deserialize':>12} {'Throughput':>12}") + print("-" * 60) + + for name, data in results["benchmarks"].get("serialization", {}).items(): + print( + f"{name:<20} {data['serialize_ms']:>10.3f}ms " + f"{data['deserialize_ms']:>10.3f}ms " + f"{data['serialize_throughput_mb_s']:>10.1f} MB/s" + ) + + print("\n" + "=" * 80) + + +def main(): + parser = argparse.ArgumentParser(description="Cortex Benchmark Suite") + parser.add_argument( + "-o", "--output", type=str, default=None, help="Output file for JSON results" + ) + parser.add_argument( + "--quick", + action="store_true", + help="Run quick benchmarks with fewer iterations", + ) + + args = parser.parse_args() + + results = run_all_benchmarks() + print_summary(results) + + if args.output: + output_path = Path(args.output) + with open(output_path, "w") as f: + json.dump(results, f, indent=2, default=str) + print(f"\nResults saved to: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/bench_latency.py b/benchmarks/bench_latency.py new file mode 100644 index 0000000..a47bc53 --- /dev/null +++ b/benchmarks/bench_latency.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Latency benchmark for Cortex. + +Measures round-trip latency between publisher and subscriber. +""" + +from __future__ import annotations + +import argparse +import asyncio +import multiprocessing as mp +import statistics + +# Add parent to path for imports +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from cortex.core.publisher import Publisher +from cortex.core.subscriber import Subscriber +from cortex.discovery.daemon import DiscoveryDaemon +from cortex.messages.base import Message + + +@dataclass +class LatencyMessage(Message): + """Message with timestamp for latency measurement.""" + + send_time_ns: int + sequence: int + payload: bytes # Variable size payload + + +def run_discovery_daemon(): + """Run the discovery daemon in a separate process.""" + daemon = DiscoveryDaemon() + daemon.start() + + +def run_publisher( + topic: str, + num_messages: int, + payload_size: int, + rate_hz: float, + ready_event, + start_event, +): + """Publisher process.""" + time.sleep(0.5) # Wait for discovery daemon + + pub = Publisher( + topic_name=topic, + message_type=LatencyMessage, + node_name="latency_publisher", + ) + + # Signal ready + ready_event.set() + + # Wait for start signal + start_event.wait() + + payload = b"\x00" * payload_size + interval = 1.0 / rate_hz if rate_hz > 0 else 0 + + for i in range(num_messages): + msg = LatencyMessage( + send_time_ns=time.time_ns(), + sequence=i, + payload=payload, + ) + pub.publish(msg) + + if interval > 0: + time.sleep(interval) + + # Send final message to signal completion + time.sleep(0.1) + pub.close() + + +def run_subscriber( + topic: str, + num_messages: int, + ready_event, + start_event, + results_queue, +): + """Subscriber process - runs async receive loop.""" + + async def subscriber_main(): + await asyncio.sleep(0.5) # Wait for discovery daemon + + latencies: list[float] = [] + received = 0 + + sub = Subscriber( + topic_name=topic, + message_type=LatencyMessage, + node_name="latency_subscriber", + wait_for_topic=True, + topic_timeout=10.0, + ) + + # Signal ready + ready_event.set() + + # Wait for start signal + start_event.wait() + + start_time = time.time() + timeout = 30.0 # Max wait time + + while received < num_messages and (time.time() - start_time) < timeout: + try: + result = await asyncio.wait_for(sub.receive(), timeout=1.0) + + if result: + msg, _header = result + receive_time_ns = time.time_ns() + latency_us = (receive_time_ns - msg.send_time_ns) / 1000.0 + latencies.append(latency_us) + received += 1 + except asyncio.TimeoutError: + continue + + sub.close() + + # Send results back + results_queue.put( + { + "received": received, + "latencies": latencies, + } + ) + + asyncio.run(subscriber_main()) + + +def run_latency_benchmark( + num_messages: int = 1000, + payload_size: int = 1024, + rate_hz: float = 1000.0, +) -> dict: + """ + Run the latency benchmark. + + Args: + num_messages: Number of messages to send + payload_size: Size of payload in bytes + rate_hz: Publishing rate (0 for unlimited) + + Returns: + Dictionary with benchmark results + """ + topic = "/benchmark/latency" + + # Start discovery daemon + discovery_proc = mp.Process(target=run_discovery_daemon, daemon=True) + discovery_proc.start() + time.sleep(1.0) # Give daemon more time to start and bind socket + + # Events for synchronization + pub_ready = mp.Event() + sub_ready = mp.Event() + start_event = mp.Event() + results_queue = mp.Queue() + + # Start subscriber first + sub_proc = mp.Process( + target=run_subscriber, + args=(topic, num_messages, sub_ready, start_event, results_queue), + ) + sub_proc.start() + + # Start publisher + pub_proc = mp.Process( + target=run_publisher, + args=(topic, num_messages, payload_size, rate_hz, pub_ready, start_event), + ) + pub_proc.start() + + # Wait for both to be ready + pub_ready.wait(timeout=10) + sub_ready.wait(timeout=10) + + # Small delay for connection establishment + time.sleep(0.2) + + # Start benchmark + benchmark_start = time.time() + start_event.set() + + # Wait for completion + pub_proc.join(timeout=60) + sub_proc.join(timeout=60) + + benchmark_duration = time.time() - benchmark_start + + # Get results + results = results_queue.get(timeout=5) + + # Cleanup + discovery_proc.terminate() + discovery_proc.join(timeout=2) + + # Calculate statistics + latencies = results["latencies"] + + if latencies: + stats = { + "num_messages": num_messages, + "payload_size": payload_size, + "rate_hz": rate_hz, + "received": results["received"], + "loss_rate": (num_messages - results["received"]) / num_messages * 100, + "duration_s": benchmark_duration, + "latency_min_us": min(latencies), + "latency_max_us": max(latencies), + "latency_mean_us": statistics.mean(latencies), + "latency_median_us": statistics.median(latencies), + "latency_std_us": statistics.stdev(latencies) if len(latencies) > 1 else 0, + "latency_p50_us": np.percentile(latencies, 50), + "latency_p90_us": np.percentile(latencies, 90), + "latency_p99_us": np.percentile(latencies, 99), + "throughput_msg_per_s": results["received"] / benchmark_duration, + } + else: + stats = { + "error": "No messages received", + "received": 0, + } + + return stats + + +def print_results(results: dict) -> None: + """Print benchmark results in a formatted way.""" + print("\n" + "=" * 60) + print("LATENCY BENCHMARK RESULTS") + print("=" * 60) + + if "error" in results: + print(f"ERROR: {results['error']}") + return + + print("\nConfiguration:") + print(f" Messages: {results['num_messages']:,}") + print(f" Payload size: {results['payload_size']:,} bytes") + print(f" Target rate: {results['rate_hz']:,.0f} Hz") + + print("\nDelivery:") + print(f" Received: {results['received']:,} / {results['num_messages']:,}") + print(f" Loss rate: {results['loss_rate']:.2f}%") + print(f" Duration: {results['duration_s']:.2f} s") + print(f" Throughput: {results['throughput_msg_per_s']:,.0f} msg/s") + + print("\nLatency (microseconds):") + print(f" Min: {results['latency_min_us']:,.1f} µs") + print(f" Max: {results['latency_max_us']:,.1f} µs") + print(f" Mean: {results['latency_mean_us']:,.1f} µs") + print(f" Median: {results['latency_median_us']:,.1f} µs") + print(f" Std Dev: {results['latency_std_us']:,.1f} µs") + print(f" P50: {results['latency_p50_us']:,.1f} µs") + print(f" P90: {results['latency_p90_us']:,.1f} µs") + print(f" P99: {results['latency_p99_us']:,.1f} µs") + + print("=" * 60 + "\n") + + +def main(): + parser = argparse.ArgumentParser(description="Cortex Latency Benchmark") + parser.add_argument( + "-n", + "--num-messages", + type=int, + default=1000, + help="Number of messages to send (default: 1000)", + ) + parser.add_argument( + "-s", + "--payload-size", + type=int, + default=1024, + help="Payload size in bytes (default: 1024)", + ) + parser.add_argument( + "-r", + "--rate", + type=float, + default=1000.0, + help="Publishing rate in Hz, 0 for unlimited (default: 1000)", + ) + + args = parser.parse_args() + + print("\nRunning latency benchmark...") + print(f" Messages: {args.num_messages}") + print(f" Payload: {args.payload_size} bytes") + print(f" Rate: {args.rate} Hz") + + results = run_latency_benchmark( + num_messages=args.num_messages, + payload_size=args.payload_size, + rate_hz=args.rate, + ) + + print_results(results) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/bench_throughput.py b/benchmarks/bench_throughput.py new file mode 100644 index 0000000..f894853 --- /dev/null +++ b/benchmarks/bench_throughput.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +""" +Throughput benchmark for Cortex framework. + +Measures maximum message throughput for different payload sizes. +""" + +from __future__ import annotations + +import asyncio +import builtins +import contextlib +import logging +import threading +import time +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from cortex import Publisher, Subscriber +from cortex.discovery import DiscoveryDaemon +from cortex.messages import ArrayMessage +from cortex.messages.base import MessageHeader + +# Reduce logging noise during benchmarks +logging.getLogger("cortex").setLevel(logging.WARNING) + + +@dataclass +class ThroughputResult: + """Results from a throughput test.""" + + payload_size: int + messages_sent: int + messages_received: int + duration: float + throughput_msgs: float # messages per second + throughput_bytes: float # bytes per second + throughput_mbps: float # megabits per second + loss_rate: float + + +def run_throughput_test( + payload_size: int, + duration_seconds: float = 5.0, + discovery_address: str = "ipc:///tmp/cortex_benchmark_discovery", +) -> ThroughputResult: + """ + Run a throughput test with given payload size. + + Args: + payload_size: Size of payload in bytes + duration_seconds: How long to run the test + discovery_address: Discovery daemon address + + Returns: + ThroughputResult with benchmark data + """ + topic = "/benchmark/throughput" + + # Create payload (numpy array of given size) + # Each float64 is 8 bytes + num_elements = max(1, payload_size // 8) + payload = np.random.rand(num_elements).astype(np.float64) + actual_payload_size = payload.nbytes + + # Counters + received_count = 0 + lock = threading.Lock() + + # Subscriber callback (async) + async def on_message(msg: ArrayMessage, header: MessageHeader) -> None: + nonlocal received_count + with lock: + received_count += 1 + + # Start discovery daemon + daemon = DiscoveryDaemon(address=discovery_address) + daemon_thread = threading.Thread(target=daemon.start, daemon=True) + daemon_thread.start() + time.sleep(1.0) # Give daemon more time to start and bind socket + + sent_count = 0 + + try: + # Create publisher + pub = Publisher( + topic_name=topic, + message_type=ArrayMessage, + node_name="throughput_pub", + discovery_address=discovery_address, + queue_size=10000, # Large queue for throughput test + ) + + # Create subscriber + sub = Subscriber( + topic_name=topic, + message_type=ArrayMessage, + callback=on_message, + node_name="throughput_sub", + discovery_address=discovery_address, + queue_size=10000, + ) + + # Wait for connection + time.sleep(0.5) + + # Start subscriber in background using asyncio + sub_running = True + + def subscriber_loop(): + async def run_sub(): + sub.start() + while sub_running: + try: + result = await asyncio.wait_for(sub.receive(), timeout=0.01) + if result and sub._callback: + msg, header = result + await sub._callback(msg, header) + except asyncio.TimeoutError: + pass + except Exception: + break + + asyncio.run(run_sub()) + + sub_thread = threading.Thread(target=subscriber_loop, daemon=True) + sub_thread.start() + + # Run publisher at maximum speed for specified duration + start_time = time.perf_counter() + end_time = start_time + duration_seconds + + message = ArrayMessage(data=payload) + + while time.perf_counter() < end_time: + if pub.publish(message): + sent_count += 1 + + actual_duration = time.perf_counter() - start_time + + # Give subscriber time to catch up + time.sleep(0.5) + sub_running = False + sub_thread.join(timeout=1.0) + + # Calculate results + with lock: + final_received = received_count + + throughput_msgs = final_received / actual_duration + throughput_bytes = throughput_msgs * actual_payload_size + throughput_mbps = (throughput_bytes * 8) / 1_000_000 # Convert to megabits + + loss_rate = 1.0 - (final_received / sent_count) if sent_count > 0 else 0.0 + + return ThroughputResult( + payload_size=actual_payload_size, + messages_sent=sent_count, + messages_received=final_received, + duration=actual_duration, + throughput_msgs=throughput_msgs, + throughput_bytes=throughput_bytes, + throughput_mbps=throughput_mbps, + loss_rate=loss_rate, + ) + + finally: + # Cleanup + with contextlib.suppress(builtins.BaseException): + pub.close() + with contextlib.suppress(builtins.BaseException): + sub.close() + daemon.stop() + + +def run_throughput_benchmark( + num_messages: int = 1000, + array_shape: tuple[int, ...] = (100, 100), + dtype: str = "float32", +) -> dict[str, Any]: + """ + Run throughput benchmark (compatibility wrapper for bench_all.py). + + Args: + num_messages: Number of messages to send + array_shape: Shape of array to send + dtype: NumPy dtype string + + Returns: + Dictionary with benchmark results + """ + import uuid + + # Calculate payload size + test_array = np.zeros(array_shape, dtype=dtype) + payload_size = test_array.nbytes + + # Estimate duration based on message count + # Assume roughly 10000 msg/s for estimation + duration = max(1.0, num_messages / 10000) + + # Use unique discovery address to avoid conflicts + unique_id = uuid.uuid4().hex[:8] + discovery_address = f"ipc:///tmp/cortex_bench_{unique_id}" + + result = run_throughput_test( + payload_size=payload_size, + duration_seconds=duration, + discovery_address=discovery_address, + ) + + return { + "num_messages": num_messages, + "array_shape": array_shape, + "dtype": dtype, + "payload_size_bytes": result.payload_size, + "messages_sent": result.messages_sent, + "messages_received": result.messages_received, + "duration_s": result.duration, + "throughput_msg_per_s": result.throughput_msgs, + "throughput_mb_per_s": result.throughput_bytes / 1_000_000, + "loss_rate_percent": result.loss_rate * 100, + } + + +def format_bytes(num_bytes: float) -> str: + """Format bytes in human-readable form.""" + for unit in ["B", "KB", "MB", "GB"]: + if abs(num_bytes) < 1024.0: + return f"{num_bytes:.1f} {unit}" + num_bytes /= 1024.0 + return f"{num_bytes:.1f} TB" + + +def format_rate(rate: float) -> str: + """Format rate in human-readable form.""" + if rate >= 1_000_000: + return f"{rate / 1_000_000:.2f}M" + elif rate >= 1_000: + return f"{rate / 1_000:.2f}K" + else: + return f"{rate:.0f}" + + +def main(): + """Run throughput benchmarks.""" + print("=" * 70) + print("CORTEX THROUGHPUT BENCHMARK") + print("=" * 70) + print() + + # Test different payload sizes + payload_sizes = [ + 64, # 64 B - small messages + 256, # 256 B + 1024, # 1 KB + 4096, # 4 KB + 16384, # 16 KB + 65536, # 64 KB + 262144, # 256 KB + 1048576, # 1 MB - large messages + 4194304, # 4 MB - very large (like images) + ] + + duration = 3.0 # seconds per test + results: list[ThroughputResult] = [] + + print(f"Running throughput tests ({duration}s each)...") + print() + + for i, size in enumerate(payload_sizes): + print( + f" [{i + 1}/{len(payload_sizes)}] Testing {format_bytes(size)} payload...", + end=" ", + flush=True, + ) + + try: + result = run_throughput_test( + payload_size=size, + duration_seconds=duration, + discovery_address=f"ipc:///tmp/cortex_bench_{i}", + ) + results.append(result) + print( + f"✓ {format_rate(result.throughput_msgs)}/s, {result.throughput_mbps:.1f} Mbps" + ) + except Exception as e: + print(f"✗ Error: {e}") + + time.sleep(0.5) # Brief pause between tests + + # Print summary table + print() + print("=" * 70) + print("RESULTS SUMMARY") + print("=" * 70) + print() + print( + f"{'Payload':<12} {'Sent':<10} {'Recv':<10} {'Loss':<8} {'Msg/s':<12} {'Throughput':<15}" + ) + print("-" * 70) + + for r in results: + print( + f"{format_bytes(r.payload_size):<12} " + f"{r.messages_sent:<10,} " + f"{r.messages_received:<10,} " + f"{r.loss_rate * 100:>5.1f}% " + f"{format_rate(r.throughput_msgs):<12}/s " + f"{r.throughput_mbps:>8.1f} Mbps" + ) + + print("-" * 70) + print() + + # Find peak throughput + if results: + peak_msgs = max(results, key=lambda r: r.throughput_msgs) + peak_bytes = max(results, key=lambda r: r.throughput_bytes) + + print("Peak Performance:") + print( + f" Messages: {format_rate(peak_msgs.throughput_msgs)}/s @ {format_bytes(peak_msgs.payload_size)} payload" + ) + print( + f" Bandwidth: {peak_bytes.throughput_mbps:.1f} Mbps @ {format_bytes(peak_bytes.payload_size)} payload" + ) + print(f" ({peak_bytes.throughput_bytes / 1_000_000:.1f} MB/s)") + + print() + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..1fc7df0 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Examples package for Cortex framework.""" diff --git a/examples/multi_node_system.py b/examples/multi_node_system.py new file mode 100644 index 0000000..3d44b79 --- /dev/null +++ b/examples/multi_node_system.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Example: Multi-node system with custom messages. + +This example demonstrates a more complete system with: +- Custom message types +- Multiple nodes communicating +- Timer-based publishing +- Using asyncio for concurrent node execution + +Usage: + # Terminal 1: Start discovery daemon + python -m cortex.discovery.daemon + + # Terminal 2: Run the multi-node example + python examples/multi_node_system.py +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +from dataclasses import dataclass + +import numpy as np + +from cortex import Message, Node +from cortex.messages.base import MessageHeader + + +# Define custom message types +@dataclass +class SensorReading(Message): + """Raw sensor reading from a sensor.""" + + sensor_id: str + timestamp: float + values: np.ndarray # Raw sensor values + temperature: float # Sensor temperature + + +@dataclass +class ProcessedData(Message): + """Processed data after filtering.""" + + source_sensor: str + timestamp: float + filtered_values: np.ndarray + statistics: dict # mean, std, min, max + + +@dataclass +class SystemStatus(Message): + """Overall system status.""" + + timestamp: float + num_sensors: int + processing_rate_hz: float + total_messages: int + + +class SensorNode(Node): + """Simulates a sensor that publishes raw readings.""" + + def __init__(self, sensor_id: str, publish_rate: float = 10.0) -> None: + super().__init__(f"sensor_{sensor_id}") + self.sensor_id = sensor_id + self._count = 0 + + # Create publisher + self.reading_pub = self.create_publisher( + f"/sensor/{sensor_id}/raw", SensorReading + ) + + # Create timer for publishing + self.create_timer(1.0 / publish_rate, self._publish_reading) + + print(f"[{self.name}] Initialized, publishing at {publish_rate} Hz") + + async def _publish_reading(self) -> None: + """Publish a sensor reading.""" + # Simulate sensor data + t = time.time() + values = np.sin(np.linspace(0, 2 * np.pi, 100) + t) + np.random.randn(100) * 0.1 + + msg = SensorReading( + sensor_id=self.sensor_id, + timestamp=t, + values=values.astype(np.float32), + temperature=25.0 + np.random.randn() * 0.5, + ) + + self.reading_pub.publish(msg) + self._count += 1 + + +class ProcessorNode(Node): + """Processes raw sensor data and publishes filtered results.""" + + def __init__(self, sensor_ids: list[str]) -> None: + super().__init__("processor") + self.sensor_ids = sensor_ids + self._process_count = 0 + + # Create subscribers for each sensor + for sid in sensor_ids: + self.create_subscriber( + f"/sensor/{sid}/raw", SensorReading, callback=self._on_sensor_reading + ) + + # Create publisher for processed data + self.processed_pub = self.create_publisher("/processed/data", ProcessedData) + + print(f"[{self.name}] Initialized, subscribing to {len(sensor_ids)} sensors") + + async def _on_sensor_reading( + self, msg: SensorReading, header: MessageHeader + ) -> None: + """Process incoming sensor data.""" + # Simple low-pass filter + kernel_size = 5 + kernel = np.ones(kernel_size) / kernel_size + filtered = np.convolve(msg.values, kernel, mode="same") + + # Compute statistics + stats = { + "mean": float(np.mean(filtered)), + "std": float(np.std(filtered)), + "min": float(np.min(filtered)), + "max": float(np.max(filtered)), + } + + processed = ProcessedData( + source_sensor=msg.sensor_id, + timestamp=msg.timestamp, + filtered_values=filtered.astype(np.float32), + statistics=stats, + ) + + self.processed_pub.publish(processed) + self._process_count += 1 + + +class MonitorNode(Node): + """Monitors the system and logs processed data.""" + + def __init__(self) -> None: + super().__init__("monitor") + self._message_count = 0 + self._last_status_time = time.time() + + # Subscribe to processed data + self.create_subscriber( + "/processed/data", ProcessedData, callback=self._on_processed_data + ) + + # Publish system status + self.status_pub = self.create_publisher("/system/status", SystemStatus) + + # Timer for status updates + self.create_timer(1.0, self._publish_status) + + print(f"[{self.name}] Initialized") + + async def _on_processed_data( + self, msg: ProcessedData, header: MessageHeader + ) -> None: + """Handle processed data.""" + self._message_count += 1 + + # Log every 10th message + if self._message_count % 10 == 0: + stats = msg.statistics + print( + f"[{self.name}] Sensor {msg.source_sensor}: " + f"mean={stats['mean']:.3f}, std={stats['std']:.3f}" + ) + + async def _publish_status(self) -> None: + """Publish system status.""" + now = time.time() + elapsed = now - self._last_status_time + rate = self._message_count / elapsed if elapsed > 0 else 0 + + status = SystemStatus( + timestamp=now, + num_sensors=2, # We have 2 sensors in this example + processing_rate_hz=rate, + total_messages=self._message_count, + ) + + self.status_pub.publish(status) + + print( + f"[{self.name}] System status: {self._message_count} messages, " + f"{rate:.1f} Hz processing rate" + ) + + self._last_status_time = now + self._message_count = 0 + + +async def main() -> None: + """Run the multi-node system.""" + print("Starting multi-node system example...") + print("This demonstrates:") + print(" - Custom message types") + print(" - Multiple nodes with pub/sub") + print(" - Timer-based publishing") + print() + + # Create nodes + sensor_ids = ["lidar", "camera"] + + sensor_nodes = [SensorNode(sid, publish_rate=10.0) for sid in sensor_ids] + processor_node = ProcessorNode(sensor_ids) + monitor_node = MonitorNode() + + all_nodes = [*sensor_nodes, processor_node, monitor_node] + + # Give time for connections + await asyncio.sleep(1.0) + + print("\nSystem running. Press Ctrl+C to stop.\n") + + try: + # Run all nodes concurrently + await asyncio.gather(*[node.run() for node in all_nodes]) + except asyncio.CancelledError: + pass + finally: + # Close all nodes + for node in all_nodes: + await node.close() + print("System stopped.") + + +if __name__ == "__main__": + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(main()) diff --git a/examples/publisher_dict.py b/examples/publisher_dict.py new file mode 100644 index 0000000..c6ba422 --- /dev/null +++ b/examples/publisher_dict.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Example: Dictionary message publisher. + +This example demonstrates publishing complex nested dictionaries +with mixed types including NumPy arrays. + +Usage: + # Terminal 1: Start discovery daemon + python -m cortex.discovery.daemon + + # Terminal 2: Start publisher + python examples/publisher_dict.py + + # Terminal 3: Start subscriber + python examples/subscriber_dict.py +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time + +import numpy as np + +from cortex import DictMessage, Node + + +class DictPublisherNode(Node): + """Node that publishes robot state as dictionary messages.""" + + def __init__(self) -> None: + super().__init__(name="dict_publisher") + self._count = 0 + self._pub = self.create_publisher( + topic_name="/robot/state", + message_type=DictMessage, + ) + # 5 Hz publishing rate + self.create_timer(1.0 / 5.0, self._publish_state) + print("Publishing on /robot/state at 5 Hz") + print("Press Ctrl+C to stop") + + async def _publish_state(self) -> None: + """Publish robot state data.""" + state = { + "timestamp": time.time(), + "frame_id": "base_link", + "pose": { + "position": { + "x": np.sin(self._count * 0.1) * 2.0, + "y": np.cos(self._count * 0.1) * 2.0, + "z": 0.0, + }, + "orientation": { + "x": 0.0, + "y": 0.0, + "z": np.sin(self._count * 0.05), + "w": np.cos(self._count * 0.05), + }, + }, + "velocity": { + "linear": np.array([0.5, 0.0, 0.0], dtype=np.float32), + "angular": np.array([0.0, 0.0, 0.1], dtype=np.float32), + }, + "joint_positions": np.random.randn(7).astype(np.float32), + "joint_names": [ + "joint_1", + "joint_2", + "joint_3", + "joint_4", + "joint_5", + "joint_6", + "joint_7", + ], + "status": { + "is_moving": True, + "battery_level": 85.5, + "error_code": 0, + }, + } + + msg = DictMessage(data=state) + self._pub.publish(msg) + + if self._count % 5 == 0: + pos = state["pose"]["position"] + print(f"Published state {self._count}: pos=({pos['x']:.2f}, {pos['y']:.2f})") + + self._count += 1 + + +async def main() -> None: + """Run the dictionary publisher example.""" + print("Starting dictionary publisher...") + + node = DictPublisherNode() + + try: + await node.run() + except asyncio.CancelledError: + pass + finally: + await node.close() + print("Shutting down...") + + +if __name__ == "__main__": + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(main()) diff --git a/examples/publisher_numpy.py b/examples/publisher_numpy.py new file mode 100644 index 0000000..f6d58fc --- /dev/null +++ b/examples/publisher_numpy.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Example: Simple NumPy array publisher using asyncio. + +This example demonstrates publishing NumPy arrays using the Cortex framework. +Run this alongside subscriber_numpy.py to see the data transfer. + +Usage: + # Terminal 1: Start discovery daemon + python -m cortex.discovery.daemon + + # Terminal 2: Start publisher + python examples/publisher_numpy.py + + # Terminal 3: Start subscriber + python examples/subscriber_numpy.py +""" + +import asyncio + +import numpy as np + +from cortex import ArrayMessage, Node + + +class ArrayPublisherNode(Node): + """Example node that publishes NumPy arrays.""" + + def __init__(self): + super().__init__(name="array_publisher") + + # Create a publisher for array data + self.pub = self.create_publisher( + topic_name="/sensor/array_data", + message_type=ArrayMessage, + ) + + # Create a timer to publish at 10 Hz + self.count = 0 + self.create_timer(1 / 10, self.publish_array) + + print("Publishing on /sensor/array_data") + print("Press Ctrl+C to stop") + + async def publish_array(self): + """Publish array data at a constant rate.""" + # Generate some random sensor data + # Simulating a 64x64 grayscale image + data = np.random.randn(64, 64).astype(np.float32) + + # Add some structure (moving gradient) + x = np.linspace(0, 2 * np.pi, 64) + y = np.linspace(0, 2 * np.pi, 64) + X, Y = np.meshgrid(x, y) + data += np.sin(X + self.count * 0.1) * np.cos(Y + self.count * 0.1) + + # Create and publish message + msg = ArrayMessage( + data=data, name=f"frame_{self.count}", frame_id="sensor_frame" + ) + + self.pub.publish(msg) + + if self.count % 10 == 0: + print( + f"Published frame {self.count}, shape={data.shape}, mean={data.mean():.3f}" + ) + + self.count += 1 + + +async def main(): + """Run the publisher example.""" + print("Starting NumPy array publisher...") + + node = ArrayPublisherNode() + + try: + await node.run() + except KeyboardInterrupt: + print("\nShutting down...") + finally: + await node.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/publisher_tensor.py b/examples/publisher_tensor.py new file mode 100644 index 0000000..9f87b7f --- /dev/null +++ b/examples/publisher_tensor.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Example: PyTorch tensor publisher using asyncio. + +This example demonstrates publishing PyTorch tensors using the Cortex framework. +Requires PyTorch to be installed. + +Usage: + # Terminal 1: Start discovery daemon + python -m cortex.discovery.daemon + + # Terminal 2: Start publisher + python examples/publisher_tensor.py + + # Terminal 3: Start subscriber + python examples/subscriber_tensor.py +""" + +import asyncio + +try: + import torch +except ImportError: + print("This example requires PyTorch. Install with: pip install torch") + exit(1) + +from cortex import Node, TensorMessage + + +class TensorPublisherNode(Node): + """Example node that publishes PyTorch tensors.""" + + def __init__(self): + super().__init__(name="tensor_publisher") + + # Create a publisher + self.pub = self.create_publisher( + topic_name="/model/features", + message_type=TensorMessage, + ) + + # Create a timer to publish at 10 Hz + self.count = 0 + self.create_timer(1 / 10, self.publish_tensor) + + print("Publishing on /model/features") + print("Press Ctrl+C to stop") + + async def publish_tensor(self): + """Publish tensor data at a constant rate.""" + # Simulate model output (e.g., image features) + # Batch of 4, 256 feature channels, 7x7 spatial + features = torch.randn(4, 256, 7, 7) + + # Add some structure + features = features + torch.sin(torch.tensor(self.count * 0.1)) + + msg = TensorMessage(data=features, name=f"features_batch_{self.count}") + + self.pub.publish(msg) + + if self.count % 10 == 0: + print( + f"Published tensor {self.count}: shape={tuple(features.shape)}, " + f"mean={features.mean():.4f}, std={features.std():.4f}" + ) + + self.count += 1 + + +async def main(): + """Run the tensor publisher example.""" + print("Starting PyTorch tensor publisher...") + print(f"PyTorch version: {torch.__version__}") + + node = TensorPublisherNode() + + try: + await node.run() + except KeyboardInterrupt: + print("\nShutting down...") + finally: + await node.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/subscriber_dict.py b/examples/subscriber_dict.py new file mode 100644 index 0000000..ea364f4 --- /dev/null +++ b/examples/subscriber_dict.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Example: Dictionary message subscriber. + +This example demonstrates receiving complex nested dictionaries +with mixed types including NumPy arrays. + +Usage: + # Terminal 1: Start discovery daemon + python -m cortex.discovery.daemon + + # Terminal 2: Start publisher + python examples/publisher_dict.py + + # Terminal 3: Start subscriber + python examples/subscriber_dict.py +""" + +from __future__ import annotations + +import asyncio +import contextlib + +from cortex import DictMessage, Node +from cortex.messages.base import MessageHeader + + +class DictSubscriberNode(Node): + """Node that subscribes to robot state dictionary messages.""" + + def __init__(self) -> None: + super().__init__(name="dict_subscriber") + print("Waiting for publisher on /robot/state...") + sub = self.create_subscriber( + topic_name="/robot/state", + message_type=DictMessage, + callback=self._on_state_received, + wait_for_topic=True, + topic_timeout=30.0, + ) + + if not sub.is_connected: + raise RuntimeError("Failed to connect to topic!") + + print("Connected! Receiving messages...") + print("Press Ctrl+C to stop") + print() + + async def _on_state_received(self, msg: DictMessage, header: MessageHeader) -> None: + """Callback for received state messages.""" + state = msg.data + + print(f"=== Robot State (seq={header.sequence}) ===") + print(f" Timestamp: {state['timestamp']:.3f}") + + pos = state["pose"]["position"] + print(f" Position: ({pos['x']:.3f}, {pos['y']:.3f}, {pos['z']:.3f})") + + ori = state["pose"]["orientation"] + print( + f" Orientation: ({ori['x']:.3f}, {ori['y']:.3f}, " + f"{ori['z']:.3f}, {ori['w']:.3f})" + ) + + vel = state["velocity"] + print(f" Linear Velocity: {vel['linear']}") + print(f" Angular Velocity: {vel['angular']}") + + print(f" Joint Positions: {state['joint_positions']}") + + status = state["status"] + print( + f" Status: moving={status['is_moving']}, " + f"battery={status['battery_level']}%" + ) + print() + + +async def main() -> None: + """Run the dictionary subscriber example.""" + print("Starting dictionary subscriber...") + + try: + node = DictSubscriberNode() + except RuntimeError as e: + print(e) + return + + try: + await node.run() + except asyncio.CancelledError: + pass + finally: + await node.close() + print("Shutting down...") + + +if __name__ == "__main__": + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(main()) diff --git a/examples/subscriber_numpy.py b/examples/subscriber_numpy.py new file mode 100644 index 0000000..417b5a6 --- /dev/null +++ b/examples/subscriber_numpy.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Example: Simple NumPy array subscriber using asyncio. + +This example demonstrates receiving NumPy arrays using the Cortex framework. +Run this alongside publisher_numpy.py to see the data transfer. + +Usage: + # Terminal 1: Start discovery daemon + python -m cortex.discovery.daemon + + # Terminal 2: Start publisher + python examples/publisher_numpy.py + + # Terminal 3: Start subscriber + python examples/subscriber_numpy.py +""" + +import asyncio + +from cortex import ArrayMessage, Node +from cortex.messages.base import MessageHeader + + +async def on_array_received(msg: ArrayMessage, header: MessageHeader): + """Async callback for received array messages.""" + print(f"Received: {msg.name}") + print(f" Shape: {msg.data.shape}") + print(f" Dtype: {msg.data.dtype}") + print(f" Mean: {msg.data.mean():.4f}") + print(f" Std: {msg.data.std():.4f}") + print(f" Frame ID: {msg.frame_id}") + print(f" Timestamp: {header.timestamp_ns}") + print(f" Sequence: {header.sequence}") + print() + + +class ArraySubscriberNode(Node): + """Example node that subscribes to NumPy arrays.""" + + def __init__(self): + super().__init__(name="array_subscriber") + + # Create a subscriber + print("Waiting for publisher on /sensor/array_data...") + self.sub = self.create_subscriber( + topic_name="/sensor/array_data", + message_type=ArrayMessage, + callback=on_array_received, + wait_for_topic=True, + topic_timeout=30.0, + ) + + if not self.sub.is_connected: + raise RuntimeError("Failed to connect to topic!") + + print("Connected! Receiving messages...") + print("Press Ctrl+C to stop") + print() + + +async def main(): + """Run the subscriber example.""" + print("Starting NumPy array subscriber...") + + try: + node = ArraySubscriberNode() + await node.run() + except RuntimeError as e: + print(f"Error: {e}") + except KeyboardInterrupt: + print("\nShutting down...") + finally: + if "node" in locals(): + await node.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/subscriber_tensor.py b/examples/subscriber_tensor.py new file mode 100644 index 0000000..f35df0f --- /dev/null +++ b/examples/subscriber_tensor.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Example: PyTorch tensor subscriber using asyncio. + +This example demonstrates receiving PyTorch tensors using the Cortex framework. +Requires PyTorch to be installed. + +Usage: + # Terminal 1: Start discovery daemon + python -m cortex.discovery.daemon + + # Terminal 2: Start publisher + python examples/publisher_tensor.py + + # Terminal 3: Start subscriber + python examples/subscriber_tensor.py +""" + +import asyncio + +try: + import torch +except ImportError: + print("This example requires PyTorch. Install with: pip install torch") + exit(1) + +from cortex import Node, TensorMessage +from cortex.messages.base import MessageHeader + + +async def on_tensor_received(msg: TensorMessage, header: MessageHeader): + """Async callback for received tensor messages.""" + tensor = msg.data + + print(f"Received: {msg.name}") + print(f" Shape: {tuple(tensor.shape)}") + print(f" Dtype: {tensor.dtype}") + print(f" Device: {tensor.device}") + print(f" Mean: {tensor.mean():.4f}") + print(f" Std: {tensor.std():.4f}") + print(f" Min: {tensor.min():.4f}") + print(f" Max: {tensor.max():.4f}") + print(f" Sequence: {header.sequence}") + print() + + +class TensorSubscriberNode(Node): + """Example node that subscribes to PyTorch tensors.""" + + def __init__(self): + super().__init__(name="tensor_subscriber") + + # Create a subscriber + print("Waiting for publisher on /model/features...") + self.sub = self.create_subscriber( + topic_name="/model/features", + message_type=TensorMessage, + callback=on_tensor_received, + wait_for_topic=True, + topic_timeout=30.0, + ) + + if not self.sub.is_connected: + raise RuntimeError("Failed to connect to topic!") + + print("Connected! Receiving messages...") + print("Press Ctrl+C to stop") + print() + + +async def main(): + """Run the tensor subscriber example.""" + print("Starting PyTorch tensor subscriber...") + print(f"PyTorch version: {torch.__version__}") + + try: + node = TensorSubscriberNode() + await node.run() + except RuntimeError as e: + print(f"Error: {e}") + except KeyboardInterrupt: + print("\nShutting down...") + finally: + if "node" in locals(): + await node.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e24c1b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cortex" +version = "0.1.0" +description = "A lightweight framework using ZeroMQ for inter-process communication" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.9" +authors = [ + {name = "Cortex Authors"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "pyzmq>=25.0.0", + "numpy>=1.20.0", + "msgpack>=1.0.0", +] + +[project.optional-dependencies] +torch = [ + "torch>=2.0.0", +] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-timeout>=2.0.0", + "ruff>=0.8.0", +] +all = [ + "cortex[torch,dev]", +] + +[project.scripts] +cortex-discovery = "cortex.discovery.daemon:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" +timeout = 30 +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/src/cortex/__init__.py b/src/cortex/__init__.py new file mode 100644 index 0000000..db2443a --- /dev/null +++ b/src/cortex/__init__.py @@ -0,0 +1,41 @@ +""" +Cortex: A lightweight framework using ZeroMQ for IPC. + +This framework provides: +- Publisher/Subscriber pattern for inter-process communication +- Discovery service for automatic topic resolution +- Support for numpy arrays, torch tensors, and Python dicts +- 64-bit fingerprint hashing for message type identification +- Asyncio-based architecture for cooperative multitasking +""" + +from cortex.core.executor import MultiRateExecutor, RateExecutor +from cortex.core.node import Node +from cortex.core.publisher import Publisher +from cortex.core.subscriber import Subscriber +from cortex.messages.base import Message, MessageType +from cortex.messages.standard import ( + ArrayMessage, + DictMessage, + FloatMessage, + IntMessage, + StringMessage, + TensorMessage, +) + +__version__ = "0.1.0" +__all__ = [ + "Node", + "Publisher", + "Subscriber", + "RateExecutor", + "MultiRateExecutor", + "Message", + "MessageType", + "ArrayMessage", + "TensorMessage", + "DictMessage", + "StringMessage", + "FloatMessage", + "IntMessage", +] diff --git a/src/cortex/core/__init__.py b/src/cortex/core/__init__.py new file mode 100644 index 0000000..258a3b6 --- /dev/null +++ b/src/cortex/core/__init__.py @@ -0,0 +1,18 @@ +"""Core module for Cortex framework.""" + +from cortex.core.executor import ( + MultiRateExecutor, + RateExecutor, +) +from cortex.core.node import Node +from cortex.core.publisher import Publisher +from cortex.core.subscriber import MessageCallback, Subscriber + +__all__ = [ + "Node", + "Publisher", + "Subscriber", + "MessageCallback", + "RateExecutor", + "MultiRateExecutor", +] diff --git a/src/cortex/core/executor.py b/src/cortex/core/executor.py new file mode 100644 index 0000000..634e131 --- /dev/null +++ b/src/cortex/core/executor.py @@ -0,0 +1,202 @@ +""" +Executor for managing async functions at constant rates. + +Provides a utility for executing async callbacks with precise timing, +faithful to Python's cooperative multitasking model. +""" + +import asyncio +import logging +import time +from collections.abc import Coroutine +from typing import Any, Callable, Optional + +logger = logging.getLogger("cortex.executor") + + +# Type alias for async callbacks +AsyncCallback = Callable[..., Coroutine[Any, Any, None]] + + +class RateExecutor: + """ + Utility class for executing async functions at a constant rate. + + Provides precise timing for periodic execution of async callbacks. + Uses cooperative multitasking - ideal for I/O-bound workloads in Python < 3.14. + + Example: + async def my_callback(): + print("tick") + + executor = RateExecutor(my_callback, rate_hz=10.0) + executor.start() + await executor.run() + """ + + def __init__( + self, + func: AsyncCallback, + rate_hz: Optional[float] = None, + ): + """ + Initialize constant rate executor. + + Args: + func: Async function to execute + rate_hz: Target execution rate in Hz (None for as-fast-as-possible) + """ + self.func = func + self._running = False + self._rate_hz = rate_hz + + if rate_hz is None: + self._run_impl = self._async_run + else: + self.interval = 1.0 / rate_hz + self._run_impl = self._constant_rate_run + + @property + def running(self) -> bool: + """Check if the executor is running.""" + return self._running + + def start(self) -> None: + """Start the executor.""" + self._running = True + + def stop(self) -> None: + """Stop the executor.""" + self._running = False + + async def run(self, *args, **kwargs) -> None: + """Run the executor.""" + self.start() + try: + await self._run_impl(*args, **kwargs) + finally: + self.stop() + + async def _async_run(self, *args, **kwargs) -> None: + """Run the async function as fast as possible.""" + while self._running: + try: + await self.func(*args, **kwargs) + await asyncio.sleep(0) # Yield to event loop + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in _async_run: {e}") + await asyncio.sleep(0.001) + + async def _constant_rate_run(self, *args, **kwargs) -> None: + """ + Run a function at constant rate with precise timing. + + Executions happen at exact intervals regardless of execution time. + """ + next_exec_time = time.perf_counter() + + while self._running: + try: + current_time = time.perf_counter() + + if current_time >= next_exec_time: + await self.func(*args, **kwargs) + next_exec_time += self.interval + + # If we've fallen behind, catch up + if next_exec_time < current_time: + next_exec_time = current_time + self.interval + + await asyncio.sleep(0) # Yield to event loop + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in _constant_rate_run: {e}") + await asyncio.sleep(0.001) + + # Sleep until next execution time (with small max to stay responsive) + sleep_time = min( + max(0, next_exec_time - time.perf_counter()), + 0.001, # Max sleep to stay responsive + ) + await asyncio.sleep(sleep_time) + + +class MultiRateExecutor: + """ + Execute multiple async functions concurrently at different rates. + + Each function can have its own execution rate. + + Example: + executor = MultiRateExecutor() + executor.add(sensor_read, rate_hz=100.0) + executor.add(process_data, rate_hz=30.0) + executor.add(publish_results, rate_hz=10.0) + + await executor.run() + """ + + def __init__(self): + """Initialize multi-rate executor.""" + self._executors: list[RateExecutor] = [] + self._tasks: list[asyncio.Task] = [] + self._running = False + + def add( + self, + func: AsyncCallback, + rate_hz: Optional[float] = None, + ) -> RateExecutor: + """ + Add a function to execute. + + Args: + func: Async function to execute + rate_hz: Target rate in Hz (None for as-fast-as-possible) + + Returns: + The created RateExecutor + """ + executor = RateExecutor(func=func, rate_hz=rate_hz) + self._executors.append(executor) + return executor + + @property + def running(self) -> bool: + """Check if any executor is running.""" + return self._running + + def start(self) -> None: + """Start all executors.""" + self._running = True + for executor in self._executors: + executor.start() + + def stop(self) -> None: + """Stop all executors.""" + self._running = False + for executor in self._executors: + executor.stop() + + async def run(self) -> None: + """Run all executors concurrently.""" + self.start() + + try: + self._tasks = [ + asyncio.create_task(executor.run()) for executor in self._executors + ] + + await asyncio.gather(*self._tasks, return_exceptions=True) + except asyncio.CancelledError: + pass + finally: + self.stop() + for task in self._tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*self._tasks, return_exceptions=True) + self._tasks.clear() diff --git a/src/cortex/core/node.py b/src/cortex/core/node.py new file mode 100644 index 0000000..4feb7b2 --- /dev/null +++ b/src/cortex/core/node.py @@ -0,0 +1,326 @@ +""" +Node abstraction for Cortex. + +Provides an async interface for managing publishers and subscribers. +Uses asyncio for cooperative multitasking - ideal for Python < 3.14. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Coroutine +from typing import TYPE_CHECKING, Any, Callable + +import zmq +import zmq.asyncio + +from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS +from cortex.messages.base import Message + +if TYPE_CHECKING: + from cortex.core.publisher import Publisher + from cortex.core.subscriber import Subscriber + +logger = logging.getLogger("cortex.node") + + +# Type aliases +AsyncCallback = Callable[..., Coroutine[Any, Any, None]] +MessageCallback = Callable[[Message, Any], Coroutine[Any, Any, None]] + + +class Node: + """ + A node in the Cortex communication graph. + + Nodes manage a collection of publishers and subscribers using asyncio + for cooperative multitasking. This design is faithful to Python < 3.14's + single-threaded nature for CPU-bound work. + + Example: + class CameraNode(Node): + def __init__(self): + super().__init__("camera_node") + + self.pub = self.create_publisher( + "/camera/image", + ImageMessage + ) + + self.create_timer(1/30, self.publish_image) + + async def publish_image(self): + image = capture_image() + self.pub.publish(ImageMessage(data=image)) + + async def run(self): + await super().run() + """ + + def __init__( + self, + name: str, + discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, + ): + """ + Initialize the node. + + Args: + name: Unique name for this node + discovery_address: Address of the discovery daemon + """ + self.name = name + self.discovery_address = discovery_address + + # ZMQ async context + self._context = zmq.asyncio.Context() + + # Publishers and subscribers + self._publishers: dict[str, Publisher] = {} + self._subscribers: dict[str, Subscriber] = {} + + # Timer executors: (period, callback, RateExecutor) + self._timers: list[tuple[float, AsyncCallback, Any]] = [] + + # Async executors (non-rate-limited) + self._async_executors: list[Any] = [] + + # Tasks + self._tasks: list[asyncio.Task] = [] + + # State + self._running = False + + logger.info(f"Created node: {name}") + + def create_publisher( + self, + topic_name: str, + message_type: type[Message], + queue_size: int = 10, + ) -> Publisher: + """ + Create a publisher for a topic. + + Args: + topic_name: Name of the topic + message_type: Type of messages to publish + queue_size: Output queue size + + Returns: + Publisher instance + """ + from cortex.core.publisher import Publisher + + if topic_name in self._publishers: + logger.warning(f"Publisher for {topic_name} already exists") + return self._publishers[topic_name] + + pub = Publisher( + topic_name=topic_name, + message_type=message_type, + node_name=self.name, + discovery_address=self.discovery_address, + queue_size=queue_size, + context=self._context, + ) + + self._publishers[topic_name] = pub + logger.info(f"Created publisher for {topic_name}") + + return pub + + def create_subscriber( + self, + topic_name: str, + message_type: type[Message], + callback: MessageCallback | None = None, + queue_size: int = 10, + wait_for_topic: bool = True, + topic_timeout: float = 30.0, + ) -> Subscriber: + """ + Create a subscriber for a topic. + + Args: + topic_name: Name of the topic + message_type: Type of messages expected + callback: Async function to call when messages are received + queue_size: Input queue size + wait_for_topic: Whether to wait for the topic to be available + topic_timeout: Timeout for waiting for topic + + Returns: + Subscriber instance + """ + from cortex.core.subscriber import Subscriber + + if topic_name in self._subscribers: + logger.warning(f"Subscriber for {topic_name} already exists") + return self._subscribers[topic_name] + + sub = Subscriber( + topic_name=topic_name, + message_type=message_type, + callback=callback, + node_name=self.name, + discovery_address=self.discovery_address, + queue_size=queue_size, + wait_for_topic=wait_for_topic, + topic_timeout=topic_timeout, + context=self._context, + ) + + self._subscribers[topic_name] = sub + logger.info(f"Created subscriber for {topic_name}") + + # Add subscriber's receive loop as an async executor + if callback is not None: + self._async_executors.append(sub) + + return sub + + def create_timer( + self, + period: float, + callback: AsyncCallback, + ) -> None: + """ + Create a periodic timer. + + Args: + period: Timer period in seconds + callback: Async function to call on each timer tick + """ + from cortex.core.executor import RateExecutor + + rate_hz = 1.0 / period + executor = RateExecutor(callback, rate_hz=rate_hz) + self._timers.append((period, callback, executor)) + + logger.debug(f"Created timer with period {period}s ({rate_hz} Hz)") + + def create_async_executor(self, callback: AsyncCallback) -> None: + """ + Create an async executor that runs as fast as possible. + + Args: + callback: Async function to execute continuously + """ + from cortex.core.executor import RateExecutor + + executor = RateExecutor(callback, rate_hz=None) + self._async_executors.append(executor) + + logger.debug("Created async executor") + + async def run(self) -> None: + """ + Run the node, processing messages and timers. + + This is the main async entry point for the node. + """ + self._running = True + + # Start all timer executors + for _period, _callback, executor in self._timers: + executor.start() + self._tasks.append(asyncio.create_task(executor.run())) + + # Start all async executors (including subscriber receive loops) + for executor in self._async_executors: + if hasattr(executor, "start"): + executor.start() + if hasattr(executor, "run"): + self._tasks.append(asyncio.create_task(executor.run())) + + logger.info(f"Node {self.name} running with {len(self._tasks)} tasks") + + try: + await asyncio.gather(*self._tasks, return_exceptions=True) + except asyncio.CancelledError: + logger.info(f"Node {self.name} cancelled") + finally: + self._running = False + # Stop all executors + for _period, _callback, executor in self._timers: + executor.stop() + for executor in self._async_executors: + if hasattr(executor, "stop"): + executor.stop() + + def stop(self) -> None: + """Stop the node.""" + logger.info(f"Stopping node {self.name}") + self._running = False + + # Stop all executors + for _period, _callback, executor in self._timers: + executor.stop() + for executor in self._async_executors: + if hasattr(executor, "stop"): + executor.stop() + + # Cancel all tasks + for task in self._tasks: + if not task.done(): + task.cancel() + + async def close(self) -> None: + """Close the node and release all resources.""" + logger.info(f"Closing node {self.name}") + + self.stop() + + # Wait for tasks to complete + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + self._tasks.clear() + + # Close all publishers + for pub in self._publishers.values(): + pub.close() + self._publishers.clear() + + # Close all subscribers + for sub in self._subscribers.values(): + sub.close() + self._subscribers.clear() + + self._timers.clear() + self._async_executors.clear() + + # Terminate ZMQ context + self._context.term() + + logger.info(f"Node {self.name} closed") + + def get_publisher(self, topic_name: str) -> Publisher | None: + """Get a publisher by topic name.""" + return self._publishers.get(topic_name) + + def get_subscriber(self, topic_name: str) -> Subscriber | None: + """Get a subscriber by topic name.""" + return self._subscribers.get(topic_name) + + @property + def publishers(self) -> list[str]: + """Get list of publisher topic names.""" + return list(self._publishers.keys()) + + @property + def subscribers(self) -> list[str]: + """Get list of subscriber topic names.""" + return list(self._subscribers.keys()) + + @property + def is_running(self) -> bool: + """Check if the node is running.""" + return self._running + + async def __aenter__(self) -> Node: + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.close() diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py new file mode 100644 index 0000000..f38f9fc --- /dev/null +++ b/src/cortex/core/publisher.py @@ -0,0 +1,243 @@ +""" +Publisher implementation for Cortex. + +Provides a ZeroMQ-based publisher that registers with the discovery daemon +and publishes messages on IPC sockets using asyncio. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import logging +import os +import time + +import zmq +import zmq.asyncio + +from cortex.discovery.client import DiscoveryClient +from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS +from cortex.discovery.protocol import TopicInfo +from cortex.messages.base import Message + +logger = logging.getLogger("cortex.publisher") + + +def generate_ipc_address(topic_name: str) -> str: + """ + Generate a unique IPC address for a topic. + + Uses a hash of the topic name to create a valid filesystem path. + """ + # Create a safe filename from topic name + safe_name = topic_name.replace("/", "_").lstrip("_") + # Add hash suffix for uniqueness + hash_suffix = hashlib.md5(topic_name.encode()).hexdigest()[:8] + + # Ensure the directory exists + ipc_dir = "/tmp/cortex/topics" + os.makedirs(ipc_dir, exist_ok=True) + + return f"ipc://{ipc_dir}/{safe_name}_{hash_suffix}" + + +class Publisher: + """ + Publisher for sending messages on a topic. + + Uses ZeroMQ PUB socket over IPC for efficient local communication. + Automatically registers with the discovery daemon. + + Example: + pub = Publisher( + topic_name="/camera/image", + message_type=ImageMessage, + node_name="camera_node" + ) + pub.publish(ImageMessage(data=image_array)) + """ + + def __init__( + self, + topic_name: str, + message_type: type[Message], + node_name: str = "anonymous", + discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, + queue_size: int = 10, + auto_register: bool = True, + context: zmq.asyncio.Context | None = None, + ): + """ + Initialize the publisher. + + Args: + topic_name: Name of the topic to publish on (e.g., "/camera/image") + message_type: Type of message to publish + node_name: Name of the node creating this publisher + discovery_address: Address of the discovery daemon + queue_size: High-water mark for outgoing messages + auto_register: Whether to automatically register with discovery daemon + context: Optional shared ZMQ async context + """ + self.topic_name = topic_name + self.message_type = message_type + self.node_name = node_name + self.discovery_address = discovery_address + self.queue_size = queue_size + + # Generate IPC address for this topic + self.address = generate_ipc_address(topic_name) + + # ZMQ setup - use provided context or create new one + self._context: zmq.asyncio.Context = context or zmq.asyncio.Context() + self._owns_context = context is None + self._socket: zmq.asyncio.Socket | None = None + + # Discovery client + self._discovery_client: DiscoveryClient | None = None + self._registered = False + + # Statistics + self._publish_count = 0 + self._last_publish_time: float | None = None + + # Initialize + self._setup_socket() + if auto_register: + self._register_with_discovery() + + def _setup_socket(self) -> None: + """Set up the ZMQ publisher socket.""" + # Ensure the IPC directory exists + if self.address.startswith("ipc://"): + path = self.address[6:] + dir_path = os.path.dirname(path) + os.makedirs(dir_path, exist_ok=True) + # Remove stale socket file + if os.path.exists(path): + os.remove(path) + + self._socket = self._context.socket(zmq.PUB) + + # Set high-water mark (queue size) + self._socket.setsockopt(zmq.SNDHWM, self.queue_size) + + # Bind to the address + self._socket.bind(self.address) + + logger.debug(f"Publisher socket bound to {self.address}") + + def _register_with_discovery(self) -> None: + """Register this publisher with the discovery daemon.""" + try: + self._discovery_client = DiscoveryClient( + discovery_address=self.discovery_address + ) + + topic_info = TopicInfo( + name=self.topic_name, + address=self.address, + message_type=self.message_type.__name__, + fingerprint=self.message_type.fingerprint(), + publisher_node=self.node_name, + ) + + if self._discovery_client.register_topic(topic_info): + self._registered = True + logger.info(f"Registered topic {self.topic_name} with discovery daemon") + else: + logger.warning(f"Failed to register topic {self.topic_name}") + except Exception as e: + logger.warning(f"Could not connect to discovery daemon: {e}") + + def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: + """ + Publish a message (non-blocking). + + Args: + message: The message to publish (must match message_type) + flags: ZMQ flags for sending (default: NOBLOCK) + + Returns: + True if the message was sent successfully + + Raises: + TypeError: If message type doesn't match + """ + if not isinstance(message, self.message_type): + raise TypeError( + f"Expected {self.message_type.__name__}, got {type(message).__name__}" + ) + + try: + # Serialize and send + data = message.to_bytes() + + # Send with topic name as first frame for filtering + self._socket.send_multipart( + [self.topic_name.encode("utf-8"), data], flags=flags + ) + + self._publish_count += 1 + self._last_publish_time = time.time() + + return True + except zmq.Again: + # Would block - queue full + return False + except Exception as e: + logger.error(f"Failed to publish message: {e}") + return False + + @property + def is_registered(self) -> bool: + """Check if publisher is registered with discovery daemon.""" + return self._registered + + @property + def publish_count(self) -> int: + """Get the number of messages published.""" + return self._publish_count + + @property + def last_publish_time(self) -> float | None: + """Get the timestamp of the last published message.""" + return self._last_publish_time + + def close(self) -> None: + """Close the publisher and unregister from discovery.""" + logger.info(f"Closing publisher for {self.topic_name}") + + # Unregister from discovery (best effort - daemon may be gone) + if self._discovery_client and self._registered: + with contextlib.suppress(Exception): + self._discovery_client.unregister_topic(self.topic_name) + with contextlib.suppress(Exception): + self._discovery_client.close() + self._discovery_client = None + + self._registered = False + + # Close socket + if self._socket: + self._socket.close() + self._socket = None + + # Only terminate context if we own it + if self._owns_context and self._context: + self._context.term() + self._context = None + + # Clean up IPC socket file + if self.address.startswith("ipc://"): + path = self.address[6:] + if os.path.exists(path): + with contextlib.suppress(Exception): + os.remove(path) + + def __enter__(self) -> Publisher: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py new file mode 100644 index 0000000..a36ff4a --- /dev/null +++ b/src/cortex/core/subscriber.py @@ -0,0 +1,304 @@ +""" +Subscriber implementation for Cortex. + +Provides a ZeroMQ-based subscriber that queries the discovery daemon +and subscribes to topics using IPC sockets with asyncio. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import time +from collections.abc import Coroutine +from typing import Any, Callable + +import zmq +import zmq.asyncio + +from cortex.discovery.client import DiscoveryClient +from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS +from cortex.discovery.protocol import TopicInfo +from cortex.messages.base import Message, MessageHeader + +logger = logging.getLogger("cortex.subscriber") + + +# Type for async message callback +MessageCallback = Callable[[Message, MessageHeader], Coroutine[Any, Any, None]] + + +class Subscriber: + """ + Subscriber for receiving messages on a topic. + + Uses ZeroMQ SUB socket over IPC for efficient local communication. + Automatically discovers the topic using the discovery daemon. + + Example: + async def callback(msg, header): + print(f"Received image at {header.timestamp_ns}") + + sub = Subscriber( + topic_name="/camera/image", + message_type=ImageMessage, + callback=callback + ) + + # Run as part of node + async with Node("my_node") as node: + node.create_subscriber("/topic", MyMsg, callback) + await node.run() + """ + + def __init__( + self, + topic_name: str, + message_type: type[Message], + callback: MessageCallback | None = None, + node_name: str = "anonymous", + discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, + queue_size: int = 10, + auto_connect: bool = True, + wait_for_topic: bool = True, + topic_timeout: float = 30.0, + context: zmq.asyncio.Context | None = None, + ): + """ + Initialize the subscriber. + + Args: + topic_name: Name of the topic to subscribe to + message_type: Type of message expected + callback: Async callback function for received messages + node_name: Name of the node creating this subscriber + discovery_address: Address of the discovery daemon + queue_size: High-water mark for incoming messages + auto_connect: Whether to automatically connect on creation + wait_for_topic: Whether to wait for topic to be available + topic_timeout: Timeout for waiting for topic (seconds) + context: Optional shared ZMQ async context + """ + self.topic_name = topic_name + self.message_type = message_type + self._callback = callback + self.node_name = node_name + self.discovery_address = discovery_address + self.queue_size = queue_size + self.topic_timeout = topic_timeout + + # Connection info + self._topic_info: TopicInfo | None = None + self._connected = False + + # ZMQ setup - use provided context or create new one + self._context: zmq.asyncio.Context = context or zmq.asyncio.Context() + self._owns_context = context is None + self._socket: zmq.asyncio.Socket | None = None + + # Discovery client + self._discovery_client: DiscoveryClient | None = None + + # Statistics + self._receive_count = 0 + self._last_receive_time: float | None = None + + # State + self._running = False + + # Initialize + if auto_connect: + self._connect(wait=wait_for_topic) + + def _connect(self, wait: bool = True) -> bool: + """ + Connect to the topic. + + Args: + wait: Whether to wait for the topic to be available + + Returns: + True if connected successfully + """ + try: + self._discovery_client = DiscoveryClient( + discovery_address=self.discovery_address + ) + + # Look up the topic + if wait: + logger.info(f"Waiting for topic {self.topic_name}...") + self._topic_info = self._discovery_client.wait_for_topic( + self.topic_name, timeout=self.topic_timeout + ) + else: + self._topic_info = self._discovery_client.lookup_topic(self.topic_name) + + if self._topic_info: + # Verify message type + if self._topic_info.fingerprint != self.message_type.fingerprint(): + logger.warning( + f"Message type mismatch for {self.topic_name}: " + f"expected {self.message_type.__name__}, " + f"got {self._topic_info.message_type}" + ) + + # Connect to the publisher + self._setup_socket(self._topic_info.address) + self._connected = True + logger.info( + f"Connected to topic {self.topic_name} at {self._topic_info.address}" + ) + return True + else: + logger.warning(f"Topic {self.topic_name} not found") + return False + + except Exception as e: + logger.error(f"Failed to connect to topic: {e}") + return False + + def _setup_socket(self, address: str) -> None: + """Set up the ZMQ subscriber socket.""" + self._socket = self._context.socket(zmq.SUB) + + # Set high-water mark + self._socket.setsockopt(zmq.RCVHWM, self.queue_size) + + # Subscribe to topic + self._socket.setsockopt_string(zmq.SUBSCRIBE, self.topic_name) + + # Connect to publisher + self._socket.connect(address) + + logger.debug(f"Subscriber socket connected to {address}") + + async def receive(self) -> tuple[Message, MessageHeader] | None: + """ + Receive a single message (async). + + Returns: + Tuple of (message, header) or None if not connected + """ + if not self._connected or self._socket is None: + return None + + try: + # Receive multipart message [topic, data] + frames = await self._socket.recv_multipart() + + if len(frames) != 2: + logger.warning(f"Unexpected frame count: {len(frames)}") + return None + + # Parse the message + _topic_bytes, data = frames + message, header = self.message_type.from_bytes(data) + + self._receive_count += 1 + self._last_receive_time = time.time() + + return message, header + + except zmq.Again: + return None + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Failed to receive message: {e}") + return None + + def start(self) -> None: + """Start the subscriber receive loop.""" + self._running = True + + def stop(self) -> None: + """Stop the subscriber receive loop.""" + self._running = False + + async def run(self) -> None: + """ + Run the subscriber's async receive loop. + + Continuously receives messages and calls the callback. + """ + if self._callback is None: + logger.warning(f"No callback set for subscriber {self.topic_name}") + return + + self._running = True + logger.info(f"Subscriber for {self.topic_name} running") + + while self._running: + try: + result = await self.receive() + + if result: + message, header = result + try: + await self._callback(message, header) + except Exception as e: + logger.error(f"Error in callback: {e}") + + await asyncio.sleep(0) # Yield to event loop + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in receive loop: {e}") + await asyncio.sleep(0.001) + + logger.info(f"Subscriber for {self.topic_name} stopped") + + @property + def is_connected(self) -> bool: + """Check if subscriber is connected to a publisher.""" + return self._connected + + @property + def topic_info(self) -> TopicInfo | None: + """Get information about the connected topic.""" + return self._topic_info + + @property + def receive_count(self) -> int: + """Get the number of messages received.""" + return self._receive_count + + @property + def last_receive_time(self) -> float | None: + """Get the timestamp of the last received message.""" + return self._last_receive_time + + def close(self) -> None: + """Close the subscriber.""" + logger.info(f"Closing subscriber for {self.topic_name}") + + self._running = False + + # Close discovery client (best effort - daemon may be gone) + if self._discovery_client: + with contextlib.suppress(Exception): + self._discovery_client.close() + self._discovery_client = None + + # Close socket + if self._socket: + with contextlib.suppress(Exception): + self._socket.close() + self._socket = None + + # Only terminate context if we own it + if self._owns_context and self._context: + with contextlib.suppress(Exception): + self._context.term() + self._context = None + + self._connected = False + + def __enter__(self) -> Subscriber: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() diff --git a/src/cortex/discovery/__init__.py b/src/cortex/discovery/__init__.py new file mode 100644 index 0000000..1d873d0 --- /dev/null +++ b/src/cortex/discovery/__init__.py @@ -0,0 +1,14 @@ +"""Discovery module for Cortex framework.""" + +from cortex.discovery.client import DiscoveryClient +from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS, DiscoveryDaemon +from cortex.discovery.protocol import DiscoveryCommand, DiscoveryStatus, TopicInfo + +__all__ = [ + "DiscoveryClient", + "DiscoveryDaemon", + "DEFAULT_DISCOVERY_ADDRESS", + "TopicInfo", + "DiscoveryCommand", + "DiscoveryStatus", +] diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py new file mode 100644 index 0000000..99f984b --- /dev/null +++ b/src/cortex/discovery/client.py @@ -0,0 +1,285 @@ +""" +Discovery client for Cortex. + +Provides a client interface to interact with the discovery daemon. +Uses synchronous ZMQ since discovery is typically done once at startup. +""" + +from __future__ import annotations + +import contextlib +import logging +import threading +import time + +import zmq + +from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS +from cortex.discovery.protocol import ( + DiscoveryCommand, + DiscoveryRequest, + DiscoveryResponse, + DiscoveryStatus, + TopicInfo, +) + +logger = logging.getLogger("cortex.discovery.client") + + +class DiscoveryClient: + """ + Client for interacting with the discovery daemon. + + Provides methods for registering, unregistering, and looking up topics. + Uses synchronous ZMQ since discovery is typically done at startup. + """ + + def __init__( + self, + discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, + timeout_ms: int = 5000, + retries: int = 3, + ): + """ + Initialize the discovery client. + + Args: + discovery_address: Address of the discovery daemon + timeout_ms: Request timeout in milliseconds + retries: Number of retries for failed requests + """ + self.discovery_address = discovery_address + self.timeout_ms = timeout_ms + self.retries = retries + + self._context: zmq.Context | None = None + self._socket: zmq.Socket | None = None + self._lock = threading.Lock() + + # Heartbeat thread for registered topics + self._heartbeat_topics: dict[str, bool] = {} + self._heartbeat_thread: threading.Thread | None = None + self._heartbeat_running = False + + def _ensure_connected(self) -> None: + """Ensure we have a connection to the discovery daemon.""" + if self._socket is None: + self._context = zmq.Context() + self._socket = self._context.socket(zmq.REQ) + self._socket.setsockopt(zmq.RCVTIMEO, self.timeout_ms) + self._socket.setsockopt(zmq.SNDTIMEO, self.timeout_ms) + self._socket.setsockopt(zmq.LINGER, 0) + self._socket.connect(self.discovery_address) + + def _reconnect(self) -> None: + """Reconnect to the discovery daemon.""" + self.close() + self._ensure_connected() + + def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: + """Send a request and wait for response with retries.""" + last_error = None + + for attempt in range(self.retries): + try: + with self._lock: + self._ensure_connected() + self._socket.send(request.to_bytes()) + response_bytes = self._socket.recv() + return DiscoveryResponse.from_bytes(response_bytes) + except zmq.Again: + last_error = TimeoutError( + f"Discovery request timed out after {self.timeout_ms}ms" + ) + logger.warning(f"Request timeout, attempt {attempt + 1}/{self.retries}") + self._reconnect() + except zmq.ZMQError as e: + last_error = e + logger.warning(f"ZMQ error: {e}, attempt {attempt + 1}/{self.retries}") + self._reconnect() + + raise last_error + + def register_topic(self, topic_info: TopicInfo) -> bool: + """ + Register a topic with the discovery daemon. + + Args: + topic_info: Information about the topic to register + + Returns: + True if registration was successful + """ + request = DiscoveryRequest( + command=DiscoveryCommand.REGISTER_TOPIC, topic_info=topic_info + ) + + try: + response = self._send_request(request) + if response.status == DiscoveryStatus.OK: + # Start heartbeat for this topic + self._start_heartbeat(topic_info.name) + logger.info(f"Registered topic: {topic_info.name}") + return True + else: + logger.error(f"Failed to register topic: {response.message}") + return False + except Exception as e: + logger.error(f"Failed to register topic: {e}") + return False + + def unregister_topic(self, topic_name: str) -> bool: + """ + Unregister a topic from the discovery daemon. + + Args: + topic_name: Name of the topic to unregister + + Returns: + True if unregistration was successful + """ + # Stop heartbeat for this topic + self._stop_heartbeat(topic_name) + + request = DiscoveryRequest( + command=DiscoveryCommand.UNREGISTER_TOPIC, topic_name=topic_name + ) + + try: + response = self._send_request(request) + if response.status == DiscoveryStatus.OK: + logger.info(f"Unregistered topic: {topic_name}") + return True + else: + logger.warning(f"Failed to unregister topic: {response.message}") + return False + except Exception as e: + logger.error(f"Failed to unregister topic: {e}") + return False + + def lookup_topic(self, topic_name: str) -> TopicInfo | None: + """ + Look up a topic by name. + + Args: + topic_name: Name of the topic to look up + + Returns: + TopicInfo if found, None otherwise + """ + request = DiscoveryRequest( + command=DiscoveryCommand.LOOKUP_TOPIC, topic_name=topic_name + ) + + try: + response = self._send_request(request) + if response.status == DiscoveryStatus.OK: + return response.topic_info + else: + return None + except Exception as e: + logger.error(f"Failed to lookup topic: {e}") + return None + + def wait_for_topic( + self, + topic_name: str, + timeout: float = 30.0, + poll_interval: float = 0.5, + ) -> TopicInfo | None: + """ + Wait for a topic to become available. + + Args: + topic_name: Name of the topic to wait for + timeout: Maximum time to wait in seconds + poll_interval: Time between lookup attempts in seconds + + Returns: + TopicInfo if found within timeout, None otherwise + """ + start_time = time.time() + + while time.time() - start_time < timeout: + topic_info = self.lookup_topic(topic_name) + if topic_info: + return topic_info + time.sleep(poll_interval) + + return None + + def list_topics(self) -> list[TopicInfo]: + """ + List all registered topics. + + Returns: + List of TopicInfo for all registered topics + """ + request = DiscoveryRequest(command=DiscoveryCommand.LIST_TOPICS) + + try: + response = self._send_request(request) + if response.status == DiscoveryStatus.OK: + return response.topics or [] + else: + logger.warning(f"Failed to list topics: {response.message}") + return [] + except Exception as e: + logger.error(f"Failed to list topics: {e}") + return [] + + def _start_heartbeat(self, topic_name: str) -> None: + """Start sending heartbeats for a topic.""" + self._heartbeat_topics[topic_name] = True + + if not self._heartbeat_running: + self._heartbeat_running = True + self._heartbeat_thread = threading.Thread( + target=self._heartbeat_loop, daemon=True + ) + self._heartbeat_thread.start() + + def _stop_heartbeat(self, topic_name: str) -> None: + """Stop sending heartbeats for a topic.""" + self._heartbeat_topics.pop(topic_name, None) + + def _heartbeat_loop(self) -> None: + """Background thread that sends heartbeats for registered topics.""" + while self._heartbeat_running and self._heartbeat_topics: + for topic_name in list(self._heartbeat_topics.keys()): + if topic_name in self._heartbeat_topics: + self._send_heartbeat(topic_name) + + time.sleep(10.0) # Heartbeat interval + + def _send_heartbeat(self, topic_name: str) -> None: + """Send a heartbeat for a topic.""" + request = DiscoveryRequest( + command=DiscoveryCommand.HEARTBEAT, topic_name=topic_name + ) + + try: + self._send_request(request) + except Exception as e: + logger.warning(f"Failed to send heartbeat for {topic_name}: {e}") + + def close(self) -> None: + """Close the client connection.""" + self._heartbeat_running = False + self._heartbeat_topics.clear() + + if self._socket: + with contextlib.suppress(Exception): + self._socket.close() + self._socket = None + + if self._context: + with contextlib.suppress(Exception): + self._context.term() + self._context = None + + def __enter__(self) -> DiscoveryClient: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py new file mode 100644 index 0000000..0c2fd81 --- /dev/null +++ b/src/cortex/discovery/daemon.py @@ -0,0 +1,380 @@ +""" +Discovery daemon for Cortex. + +The discovery daemon runs as a separate process and maintains a registry +of all active topics. Publishers register their topics, and subscribers +query for topic addresses. + +Default IPC address: ipc:///tmp/cortex_discovery +""" + +import contextlib +import logging +import os +import signal +import sys +import threading +import time +from typing import Optional + +import zmq + +from cortex.discovery.protocol import ( + DiscoveryCommand, + DiscoveryRequest, + DiscoveryResponse, + DiscoveryStatus, + TopicInfo, +) + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) +logger = logging.getLogger("cortex.discovery") + + +# Default discovery address +DEFAULT_DISCOVERY_ADDRESS = "ipc:///tmp/cortex_discovery" + + +class DiscoveryDaemon: + """ + Discovery daemon that maintains topic registry. + + Uses ZMQ REP socket to handle requests from publishers and subscribers. + """ + + def __init__( + self, + address: str = DEFAULT_DISCOVERY_ADDRESS, + cleanup_interval: float = 30.0, + ): + """ + Initialize the discovery daemon. + + Args: + address: ZMQ address to bind to (default: ipc:///tmp/cortex_discovery) + cleanup_interval: Interval in seconds for cleaning up stale topics + """ + self.address = address + self.cleanup_interval = cleanup_interval + + # Topic registry: topic_name -> TopicInfo + self._topics: dict[str, TopicInfo] = {} + self._topics_lock = threading.Lock() + + # Last heartbeat time for each topic + self._heartbeats: dict[str, float] = {} + + # ZMQ context and socket + self._context: Optional[zmq.Context] = None + self._socket: Optional[zmq.Socket] = None + + # Control flags + self._running = False + self._shutdown_event = threading.Event() + + def _ensure_ipc_path(self) -> None: + """Ensure the IPC socket directory exists.""" + if self.address.startswith("ipc://"): + path = self.address[6:] # Remove "ipc://" + dir_path = os.path.dirname(path) + if dir_path and not os.path.exists(dir_path): + os.makedirs(dir_path, exist_ok=True) + # Remove stale socket file if it exists + if os.path.exists(path): + os.remove(path) + + def start(self) -> None: + """Start the discovery daemon.""" + logger.info(f"Starting discovery daemon at {self.address}") + + self._ensure_ipc_path() + + self._context = zmq.Context() + self._socket = self._context.socket(zmq.REP) + self._socket.bind(self.address) + + # Set socket options for responsiveness + self._socket.setsockopt(zmq.RCVTIMEO, 1000) # 1 second timeout + + self._running = True + self._shutdown_event.clear() + + logger.info("Discovery daemon started") + + try: + self._run_loop() + except KeyboardInterrupt: + logger.info("Received interrupt signal") + finally: + self.stop() + + def _run_loop(self) -> None: + """Main event loop.""" + last_cleanup = time.time() + + while self._running and not self._shutdown_event.is_set(): + try: + # Try to receive a request + try: + request_bytes = self._socket.recv(zmq.NOBLOCK) + except zmq.Again: + # No message available, continue + time.sleep(0.01) + + # Periodic cleanup + if time.time() - last_cleanup > self.cleanup_interval: + self._cleanup_stale_topics() + last_cleanup = time.time() + continue + + # Process the request + response = self._handle_request(request_bytes) + self._socket.send(response.to_bytes()) + + except Exception as e: + logger.error(f"Error in discovery loop: {e}") + # Send error response if we received a request + try: + error_response = DiscoveryResponse( + status=DiscoveryStatus.ERROR, message=str(e) + ) + self._socket.send(error_response.to_bytes()) + except Exception as send_err: + logger.debug(f"Failed to send error response: {send_err}") + + def _handle_request(self, request_bytes: bytes) -> DiscoveryResponse: + """Handle a discovery request.""" + try: + request = DiscoveryRequest.from_bytes(request_bytes) + except Exception as e: + return DiscoveryResponse( + status=DiscoveryStatus.ERROR, message=f"Failed to parse request: {e}" + ) + + if request.command == DiscoveryCommand.REGISTER_TOPIC: + return self._handle_register(request) + elif request.command == DiscoveryCommand.UNREGISTER_TOPIC: + return self._handle_unregister(request) + elif request.command == DiscoveryCommand.LOOKUP_TOPIC: + return self._handle_lookup(request) + elif request.command == DiscoveryCommand.LIST_TOPICS: + return self._handle_list() + elif request.command == DiscoveryCommand.HEARTBEAT: + return self._handle_heartbeat(request) + elif request.command == DiscoveryCommand.SHUTDOWN: + return self._handle_shutdown() + else: + return DiscoveryResponse( + status=DiscoveryStatus.ERROR, + message=f"Unknown command: {request.command}", + ) + + def _handle_register(self, request: DiscoveryRequest) -> DiscoveryResponse: + """Handle topic registration.""" + if not request.topic_info: + return DiscoveryResponse( + status=DiscoveryStatus.ERROR, + message="Missing topic_info in register request", + ) + + topic_name = request.topic_info.name + + with self._topics_lock: + if topic_name in self._topics: + # Allow re-registration from same publisher + existing = self._topics[topic_name] + if existing.publisher_node != request.topic_info.publisher_node: + return DiscoveryResponse( + status=DiscoveryStatus.ALREADY_EXISTS, + message=f"Topic {topic_name} already registered by {existing.publisher_node}", + ) + + self._topics[topic_name] = request.topic_info + self._heartbeats[topic_name] = time.time() + + logger.info(f"Registered topic: {topic_name} at {request.topic_info.address}") + + return DiscoveryResponse( + status=DiscoveryStatus.OK, message=f"Registered topic: {topic_name}" + ) + + def _handle_unregister(self, request: DiscoveryRequest) -> DiscoveryResponse: + """Handle topic unregistration.""" + topic_name = request.topic_name or ( + request.topic_info.name if request.topic_info else None + ) + + if not topic_name: + return DiscoveryResponse( + status=DiscoveryStatus.ERROR, + message="Missing topic name in unregister request", + ) + + with self._topics_lock: + if topic_name not in self._topics: + return DiscoveryResponse( + status=DiscoveryStatus.NOT_FOUND, + message=f"Topic {topic_name} not found", + ) + + del self._topics[topic_name] + self._heartbeats.pop(topic_name, None) + + logger.info(f"Unregistered topic: {topic_name}") + + return DiscoveryResponse( + status=DiscoveryStatus.OK, message=f"Unregistered topic: {topic_name}" + ) + + def _handle_lookup(self, request: DiscoveryRequest) -> DiscoveryResponse: + """Handle topic lookup.""" + topic_name = request.topic_name + + if not topic_name: + return DiscoveryResponse( + status=DiscoveryStatus.ERROR, + message="Missing topic_name in lookup request", + ) + + with self._topics_lock: + topic_info = self._topics.get(topic_name) + + if topic_info: + return DiscoveryResponse(status=DiscoveryStatus.OK, topic_info=topic_info) + else: + return DiscoveryResponse( + status=DiscoveryStatus.NOT_FOUND, + message=f"Topic {topic_name} not found", + ) + + def _handle_list(self) -> DiscoveryResponse: + """Handle list all topics.""" + with self._topics_lock: + topics = list(self._topics.values()) + + return DiscoveryResponse(status=DiscoveryStatus.OK, topics=topics) + + def _handle_heartbeat(self, request: DiscoveryRequest) -> DiscoveryResponse: + """Handle heartbeat from publisher.""" + topic_name = request.topic_name + + if not topic_name: + return DiscoveryResponse( + status=DiscoveryStatus.ERROR, + message="Missing topic_name in heartbeat request", + ) + + with self._topics_lock: + if topic_name in self._topics: + self._heartbeats[topic_name] = time.time() + return DiscoveryResponse(status=DiscoveryStatus.OK) + else: + return DiscoveryResponse( + status=DiscoveryStatus.NOT_FOUND, + message=f"Topic {topic_name} not registered", + ) + + def _handle_shutdown(self) -> DiscoveryResponse: + """Handle shutdown request.""" + logger.info("Received shutdown command") + self._running = False + self._shutdown_event.set() + return DiscoveryResponse(status=DiscoveryStatus.OK, message="Shutting down") + + def _cleanup_stale_topics(self) -> None: + """Remove topics that haven't sent a heartbeat recently.""" + stale_threshold = time.time() - (self.cleanup_interval * 3) + + with self._topics_lock: + stale_topics = [ + name + for name, last_beat in self._heartbeats.items() + if last_beat < stale_threshold + ] + + for topic_name in stale_topics: + logger.warning(f"Removing stale topic: {topic_name}") + del self._topics[topic_name] + del self._heartbeats[topic_name] + + def stop(self) -> None: + """Stop the discovery daemon.""" + logger.info("Stopping discovery daemon") + self._running = False + self._shutdown_event.set() + + if self._socket: + try: + self._socket.setsockopt(zmq.LINGER, 0) + self._socket.close() + except Exception as e: + logger.debug(f"Error closing socket: {e}") + self._socket = None + + if self._context: + try: + self._context.term() + except zmq.ZMQError as e: + logger.debug(f"Error terminating context: {e}") + self._context = None + + # Clean up IPC socket file + if self.address.startswith("ipc://"): + path = self.address[6:] + if os.path.exists(path): + with contextlib.suppress(Exception): + os.remove(path) + + logger.info("Discovery daemon stopped") + + +def main(): + """Entry point for the discovery daemon.""" + import argparse + + parser = argparse.ArgumentParser(description="Cortex Discovery Daemon") + parser.add_argument( + "--address", + default=DEFAULT_DISCOVERY_ADDRESS, + help=f"ZMQ address to bind to (default: {DEFAULT_DISCOVERY_ADDRESS})", + ) + parser.add_argument( + "--cleanup-interval", + type=float, + default=30.0, + help="Interval for cleaning up stale topics (default: 30s)", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Logging level (default: INFO)", + ) + + args = parser.parse_args() + + # Set log level + logging.getLogger().setLevel(getattr(logging, args.log_level)) + + # Create and run daemon + daemon = DiscoveryDaemon( + address=args.address, cleanup_interval=args.cleanup_interval + ) + + # Handle signals + def signal_handler(signum, frame): + logger.info(f"Received signal {signum}") + daemon.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + daemon.start() + + +if __name__ == "__main__": + main() diff --git a/src/cortex/discovery/protocol.py b/src/cortex/discovery/protocol.py new file mode 100644 index 0000000..5040394 --- /dev/null +++ b/src/cortex/discovery/protocol.py @@ -0,0 +1,130 @@ +""" +Discovery protocol definitions for Cortex. + +Defines the request/response messages for the discovery service. +""" + +from dataclasses import dataclass +from enum import IntEnum +from typing import Optional + +import msgpack + + +class DiscoveryCommand(IntEnum): + """Commands for the discovery service.""" + + REGISTER_TOPIC = 1 + UNREGISTER_TOPIC = 2 + LOOKUP_TOPIC = 3 + LIST_TOPICS = 4 + HEARTBEAT = 5 + SHUTDOWN = 99 + + +class DiscoveryStatus(IntEnum): + """Status codes for discovery responses.""" + + OK = 0 + NOT_FOUND = 1 + ALREADY_EXISTS = 2 + ERROR = 3 + + +@dataclass +class TopicInfo: + """Information about a registered topic.""" + + name: str # Topic name (e.g., "/camera/image") + address: str # ZMQ IPC address (e.g., "ipc:///tmp/cortex/topics/camera_image") + message_type: str # Message type name + fingerprint: int # 64-bit message fingerprint + publisher_node: str # Name of the publishing node + + def to_bytes(self) -> bytes: + """Serialize topic info to bytes.""" + data = { + "name": self.name, + "address": self.address, + "message_type": self.message_type, + "fingerprint": self.fingerprint, + "publisher_node": self.publisher_node, + } + return msgpack.packb(data, use_bin_type=True) + + @classmethod + def from_bytes(cls, data: bytes) -> "TopicInfo": + """Deserialize topic info from bytes.""" + d = msgpack.unpackb(data, raw=False) + return cls(**d) + + +@dataclass +class DiscoveryRequest: + """Request message for the discovery service.""" + + command: DiscoveryCommand + topic_info: Optional[TopicInfo] = None # For REGISTER/UNREGISTER + topic_name: Optional[str] = None # For LOOKUP + + def to_bytes(self) -> bytes: + """Serialize request to bytes.""" + data = { + "command": int(self.command), + "topic_name": self.topic_name, + } + if self.topic_info: + data["topic_info"] = self.topic_info.to_bytes() + return msgpack.packb(data, use_bin_type=True) + + @classmethod + def from_bytes(cls, data: bytes) -> "DiscoveryRequest": + """Deserialize request from bytes.""" + d = msgpack.unpackb(data, raw=False) + topic_info = None + if "topic_info" in d and d["topic_info"]: + topic_info = TopicInfo.from_bytes(d["topic_info"]) + return cls( + command=DiscoveryCommand(d["command"]), + topic_info=topic_info, + topic_name=d.get("topic_name"), + ) + + +@dataclass +class DiscoveryResponse: + """Response message from the discovery service.""" + + status: DiscoveryStatus + message: str = "" + topic_info: Optional[TopicInfo] = None # For LOOKUP + topics: Optional[list[TopicInfo]] = None # For LIST_TOPICS + + def to_bytes(self) -> bytes: + """Serialize response to bytes.""" + data = { + "status": int(self.status), + "message": self.message, + } + if self.topic_info: + data["topic_info"] = self.topic_info.to_bytes() + if self.topics: + data["topics"] = [t.to_bytes() for t in self.topics] + return msgpack.packb(data, use_bin_type=True) + + @classmethod + def from_bytes(cls, data: bytes) -> "DiscoveryResponse": + """Deserialize response from bytes.""" + d = msgpack.unpackb(data, raw=False) + topic_info = None + topics = None + if "topic_info" in d and d["topic_info"]: + topic_info = TopicInfo.from_bytes(d["topic_info"]) + if "topics" in d and d["topics"]: + topics = [TopicInfo.from_bytes(t) for t in d["topics"]] + return cls( + status=DiscoveryStatus(d["status"]), + message=d.get("message", ""), + topic_info=topic_info, + topics=topics, + ) diff --git a/src/cortex/messages/__init__.py b/src/cortex/messages/__init__.py new file mode 100644 index 0000000..907228b --- /dev/null +++ b/src/cortex/messages/__init__.py @@ -0,0 +1,22 @@ +"""Messages module for Cortex framework.""" + +from cortex.messages.base import Message, MessageType +from cortex.messages.standard import ( + ArrayMessage, + DictMessage, + FloatMessage, + IntMessage, + StringMessage, + TensorMessage, +) + +__all__ = [ + "Message", + "MessageType", + "ArrayMessage", + "TensorMessage", + "DictMessage", + "StringMessage", + "FloatMessage", + "IntMessage", +] diff --git a/src/cortex/messages/base.py b/src/cortex/messages/base.py new file mode 100644 index 0000000..f04a01c --- /dev/null +++ b/src/cortex/messages/base.py @@ -0,0 +1,189 @@ +""" +Base message classes for Cortex. + +All message types should inherit from Message and use the @dataclass decorator. +The message system provides automatic serialization and 64-bit fingerprinting. +""" + +import struct +import time +from dataclasses import dataclass, fields +from typing import ClassVar, TypeVar + +from cortex.utils.hashing import ( + get_cached_fingerprint, +) +from cortex.utils.serialization import deserialize_message_data, serialize_message_data + +T = TypeVar("T", bound="Message") + + +class MessageType: + """ + Registry for message types by fingerprint. + + This allows automatic deserialization based on the 64-bit fingerprint + sent with each message. + """ + + _registry: ClassVar[dict[int, type["Message"]]] = {} + + @classmethod + def register(cls, message_class: type["Message"]) -> type["Message"]: + """Register a message class by its fingerprint.""" + fingerprint = get_cached_fingerprint(message_class) + cls._registry[fingerprint] = message_class + return message_class + + @classmethod + def get(cls, fingerprint: int) -> type["Message"] | None: + """Get a message class by fingerprint.""" + return cls._registry.get(fingerprint) + + @classmethod + def get_all(cls) -> dict[int, type["Message"]]: + """Get all registered message types.""" + return cls._registry.copy() + + @classmethod + def clear(cls) -> None: + """Clear the registry (useful for testing).""" + cls._registry.clear() + + +@dataclass +class MessageHeader: + """ + Header for all Cortex messages. + + Contains metadata that is sent with every message. + """ + + fingerprint: int # 64-bit type identifier + timestamp_ns: int # Nanosecond timestamp + sequence: int # Sequence number + + def to_bytes(self) -> bytes: + """Serialize header to bytes (24 bytes fixed size).""" + return struct.pack(">QQQ", self.fingerprint, self.timestamp_ns, self.sequence) + + @classmethod + def from_bytes(cls, data: bytes) -> "MessageHeader": + """Deserialize header from bytes.""" + fingerprint, timestamp_ns, sequence = struct.unpack(">QQQ", data[:24]) + return cls( + fingerprint=fingerprint, timestamp_ns=timestamp_ns, sequence=sequence + ) + + @classmethod + def size(cls) -> int: + """Return the fixed header size in bytes.""" + return 24 + + +@dataclass +class Message: + """ + Base class for all Cortex messages. + + Subclasses should be decorated with @dataclass and define their + fields. The message will automatically compute its fingerprint + based on the class name and field structure. + + Example: + @dataclass + class PointCloud(Message): + points: np.ndarray + colors: np.ndarray + intensity: float = 1.0 + """ + + # Class-level sequence counter + _sequence_counter: ClassVar[int] = 0 + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses.""" + super().__init_subclass__(**kwargs) + # Only register concrete classes (not abstract ones) + if not getattr(cls, "__abstractmethods__", None): + MessageType.register(cls) + + @classmethod + def fingerprint(cls) -> int: + """Get the 64-bit fingerprint for this message type.""" + return get_cached_fingerprint(cls) + + @classmethod + def _next_sequence(cls) -> int: + """Get the next sequence number.""" + seq = cls._sequence_counter + cls._sequence_counter += 1 + return seq + + def to_bytes(self) -> bytes: + """ + Serialize the message to bytes. + + Format: + - 24 bytes: header (fingerprint, timestamp, sequence) + - remaining: serialized field data + """ + # Build header + header = MessageHeader( + fingerprint=self.fingerprint(), + timestamp_ns=time.time_ns(), + sequence=self._next_sequence(), + ) + + # Get field data (excluding inherited fields from dataclass machinery) + field_data = {} + for f in fields(self): + field_data[f.name] = getattr(self, f.name) + + # Serialize + header_bytes = header.to_bytes() + data_bytes = serialize_message_data(field_data) + + return header_bytes + data_bytes + + @classmethod + def from_bytes(cls: type[T], data: bytes) -> tuple[T, MessageHeader]: + """ + Deserialize a message from bytes. + + Returns: + Tuple of (message instance, header) + """ + # Parse header + header = MessageHeader.from_bytes(data) + + # Parse field data + field_data = deserialize_message_data(data[MessageHeader.size() :]) + + # Create instance + instance = cls(**field_data) + + return instance, header + + @staticmethod + def decode(data: bytes) -> tuple["Message", MessageHeader]: + """ + Decode a message without knowing its type in advance. + + Uses the fingerprint in the header to look up the message type. + + Returns: + Tuple of (message instance, header) + + Raises: + ValueError: If the message type is not registered + """ + header = MessageHeader.from_bytes(data) + message_class = MessageType.get(header.fingerprint) + + if message_class is None: + raise ValueError( + f"Unknown message type with fingerprint: {header.fingerprint:#018x}" + ) + + return message_class.from_bytes(data) diff --git a/src/cortex/messages/standard.py b/src/cortex/messages/standard.py new file mode 100644 index 0000000..81bfe5c --- /dev/null +++ b/src/cortex/messages/standard.py @@ -0,0 +1,224 @@ +""" +Standard message types for Cortex. + +These are commonly used message types that support numpy arrays, +torch tensors, and Python dictionaries. +""" + +from dataclasses import dataclass +from typing import Any, Optional + +import numpy as np + +from cortex.messages.base import Message + +# Optional torch support +try: + import torch + + TORCH_AVAILABLE = True +except ImportError: + torch = None + TORCH_AVAILABLE = False + + +@dataclass +class StringMessage(Message): + """Simple string message.""" + + data: str + + +@dataclass +class IntMessage(Message): + """Simple integer message.""" + + data: int + + +@dataclass +class FloatMessage(Message): + """Simple float message.""" + + data: float + + +@dataclass +class BytesMessage(Message): + """Raw bytes message.""" + + data: bytes + + +@dataclass +class DictMessage(Message): + """ + Dictionary message supporting nested structures. + + Values can be primitives, numpy arrays, torch tensors, or nested dicts/lists. + """ + + data: dict[str, Any] + + +@dataclass +class ListMessage(Message): + """List message supporting mixed types.""" + + data: list[Any] + + +@dataclass +class ArrayMessage(Message): + """ + NumPy array message. + + Efficiently serializes numpy arrays of any dtype and shape. + """ + + data: np.ndarray + + # Optional metadata + name: str = "" + frame_id: str = "" + + +@dataclass +class MultiArrayMessage(Message): + """ + Multiple NumPy arrays message. + + Useful for sending related arrays together (e.g., points + colors). + """ + + arrays: dict[str, np.ndarray] + + # Optional metadata + frame_id: str = "" + + +@dataclass +class TensorMessage(Message): + """ + PyTorch tensor message. + + Preserves tensor device and requires_grad attributes. + Note: Tensors are moved to CPU for serialization. + """ + + data: Any # torch.Tensor, but using Any to avoid import issues + + # Optional metadata + name: str = "" + + def __post_init__(self): + """Validate that data is a torch tensor if torch is available.""" + if TORCH_AVAILABLE and not isinstance(self.data, torch.Tensor): + raise TypeError(f"Expected torch.Tensor, got {type(self.data)}") + + +@dataclass +class MultiTensorMessage(Message): + """ + Multiple PyTorch tensors message. + + Useful for sending model inputs/outputs together. + """ + + tensors: dict[str, Any] # Dict[str, torch.Tensor] + + +@dataclass +class ImageMessage(Message): + """ + Image message using numpy array. + + Supports common image formats (HWC or CHW layout). + """ + + data: np.ndarray # Image data as numpy array + encoding: str = "rgb8" # e.g., "rgb8", "bgr8", "mono8", "rgba8" + width: int = 0 + height: int = 0 + + def __post_init__(self): + """Auto-fill width and height from array shape.""" + if self.width == 0 and self.data is not None: + if self.data.ndim == 3: + self.height, self.width = self.data.shape[:2] + elif self.data.ndim == 2: + self.height, self.width = self.data.shape + + +@dataclass +class PointCloudMessage(Message): + """ + Point cloud message. + + Stores 3D points with optional attributes like colors and intensity. + """ + + points: np.ndarray # Nx3 array of XYZ coordinates + colors: Optional[np.ndarray] = None # Nx3 array of RGB colors (0-255) + intensity: Optional[np.ndarray] = None # Nx1 array of intensity values + normals: Optional[np.ndarray] = None # Nx3 array of normal vectors + frame_id: str = "" + + +@dataclass +class PoseMessage(Message): + """ + 6DOF pose message. + + Represents position and orientation in 3D space. + """ + + position: np.ndarray # [x, y, z] + orientation: np.ndarray # [qx, qy, qz, qw] quaternion + frame_id: str = "" + child_frame_id: str = "" + + +@dataclass +class TransformMessage(Message): + """ + Transformation matrix message. + + 4x4 homogeneous transformation matrix. + """ + + matrix: np.ndarray # 4x4 transformation matrix + frame_id: str = "" + child_frame_id: str = "" + + +@dataclass +class TimestampMessage(Message): + """ + Timestamp message for synchronization. + """ + + sec: int + nanosec: int + + @classmethod + def now(cls) -> "TimestampMessage": + """Create a timestamp for the current time.""" + import time + + t = time.time_ns() + return cls(sec=t // 1_000_000_000, nanosec=t % 1_000_000_000) + + +@dataclass +class HeaderMessage(Message): + """ + Header message with timestamp and frame info. + + Similar to ROS std_msgs/Header. + """ + + stamp_sec: int + stamp_nanosec: int + frame_id: str = "" + sequence: int = 0 diff --git a/src/cortex/utils/__init__.py b/src/cortex/utils/__init__.py new file mode 100644 index 0000000..1ff8785 --- /dev/null +++ b/src/cortex/utils/__init__.py @@ -0,0 +1,6 @@ +"""Utilities module for Cortex framework.""" + +from cortex.utils.hashing import compute_fingerprint +from cortex.utils.serialization import deserialize, serialize + +__all__ = ["serialize", "deserialize", "compute_fingerprint"] diff --git a/src/cortex/utils/hashing.py b/src/cortex/utils/hashing.py new file mode 100644 index 0000000..79d5977 --- /dev/null +++ b/src/cortex/utils/hashing.py @@ -0,0 +1,76 @@ +""" +Hashing utilities for computing 64-bit fingerprints. + +The fingerprint is used to identify message types for fast decoding +without needing to parse the entire message structure. +""" + +import hashlib +import struct +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cortex.messages.base import Message + + +def compute_fingerprint(message_class: type["Message"]) -> int: + """ + Compute a 64-bit fingerprint for a message class. + + The fingerprint is based on the fully qualified class name and + the field names/types to ensure type safety across processes. + + Args: + message_class: The message class to compute fingerprint for. + + Returns: + A 64-bit unsigned integer fingerprint. + """ + # Build a canonical string representation of the message type + class_name = f"{message_class.__module__}.{message_class.__qualname__}" + + # Include field information for structural fingerprinting + field_info = [] + if hasattr(message_class, "__dataclass_fields__"): + for name, field in message_class.__dataclass_fields__.items(): + field_type = getattr(field.type, "__name__", str(field.type)) + field_info.append(f"{name}:{field_type}") + + canonical = f"{class_name}|{','.join(sorted(field_info))}" + + # Compute SHA-256 and take first 8 bytes as 64-bit fingerprint + hash_bytes = hashlib.sha256(canonical.encode("utf-8")).digest() + fingerprint = struct.unpack(">Q", hash_bytes[:8])[0] + + return fingerprint + + +def fingerprint_to_bytes(fingerprint: int) -> bytes: + """Convert a 64-bit fingerprint to bytes (big-endian).""" + return struct.pack(">Q", fingerprint) + + +def bytes_to_fingerprint(data: bytes) -> int: + """Convert bytes to a 64-bit fingerprint (big-endian).""" + return struct.unpack(">Q", data[:8])[0] + + +# Cache for fingerprints to avoid recomputation +_fingerprint_cache: dict[type["Message"], int] = {} + + +def get_cached_fingerprint(message_class: type["Message"]) -> int: + """Get or compute fingerprint with caching.""" + if message_class not in _fingerprint_cache: + _fingerprint_cache[message_class] = compute_fingerprint(message_class) + return _fingerprint_cache[message_class] + + +def register_fingerprint(message_class: type["Message"], fingerprint: int) -> None: + """Manually register a fingerprint for a message class.""" + _fingerprint_cache[message_class] = fingerprint + + +def clear_fingerprint_cache() -> None: + """Clear the fingerprint cache (useful for testing).""" + _fingerprint_cache.clear() diff --git a/src/cortex/utils/serialization.py b/src/cortex/utils/serialization.py new file mode 100644 index 0000000..6f33fd9 --- /dev/null +++ b/src/cortex/utils/serialization.py @@ -0,0 +1,317 @@ +""" +Serialization utilities for Cortex messages. + +Supports efficient serialization of: +- NumPy arrays +- PyTorch tensors (optional) +- Python dictionaries +- Primitive types +""" + +import struct +from enum import IntEnum +from typing import Any + +import msgpack +import numpy as np + +# Optional torch support +try: + import torch + + TORCH_AVAILABLE = True +except ImportError: + torch = None + TORCH_AVAILABLE = False + + +class DataType(IntEnum): + """Type identifiers for serialized data.""" + + NONE = 0 + PRIMITIVE = 1 # int, float, str, bool, None + NUMPY = 2 + TORCH = 3 + DICT = 4 + LIST = 5 + BYTES = 6 + + +def serialize_numpy(arr: np.ndarray) -> bytes: + """ + Serialize a NumPy array to bytes. + + Format: + - 1 byte: number of dimensions + - 4 bytes per dim: shape + - variable: dtype string length (2 bytes) + dtype string + - remaining: raw array data + """ + ndim = arr.ndim + shape = arr.shape + dtype_str = str(arr.dtype).encode("utf-8") + + # Pack header: ndim (1 byte) + shape (4 bytes each) + dtype + header = struct.pack(f">B{ndim}I", ndim, *shape) + header += struct.pack(">H", len(dtype_str)) + dtype_str + + # Get contiguous array data + data = np.ascontiguousarray(arr).tobytes() + + return header + data + + +def deserialize_numpy(data: bytes) -> tuple[np.ndarray, int]: + """ + Deserialize bytes to a NumPy array. + + Returns: + Tuple of (array, bytes_consumed) + """ + offset = 0 + + # Read ndim + ndim = struct.unpack(">B", data[offset : offset + 1])[0] + offset += 1 + + # Read shape + shape = struct.unpack(f">{ndim}I", data[offset : offset + 4 * ndim]) + offset += 4 * ndim + + # Read dtype + dtype_len = struct.unpack(">H", data[offset : offset + 2])[0] + offset += 2 + dtype_str = data[offset : offset + dtype_len].decode("utf-8") + offset += dtype_len + + # Calculate data size and read + dtype = np.dtype(dtype_str) + size = int(np.prod(shape)) * dtype.itemsize + arr_data = data[offset : offset + size] + offset += size + + arr = np.frombuffer(arr_data, dtype=dtype).reshape(shape) + return arr.copy(), offset + + +def serialize_torch(tensor: "torch.Tensor") -> bytes: + """ + Serialize a PyTorch tensor to bytes. + + Converts to NumPy for serialization, preserving device and requires_grad info. + """ + if not TORCH_AVAILABLE: + raise RuntimeError("PyTorch is not available") + + # Store metadata + device_str = str(tensor.device).encode("utf-8") + requires_grad = tensor.requires_grad + + # Convert to numpy (move to CPU if needed) + arr = tensor.detach().cpu().numpy() + + # Pack metadata + meta = struct.pack(">?H", requires_grad, len(device_str)) + device_str + + # Serialize the numpy array + arr_bytes = serialize_numpy(arr) + + return meta + arr_bytes + + +def deserialize_torch(data: bytes) -> tuple["torch.Tensor", int]: + """ + Deserialize bytes to a PyTorch tensor. + + Returns: + Tuple of (tensor, bytes_consumed) + """ + if not TORCH_AVAILABLE: + raise RuntimeError("PyTorch is not available") + + offset = 0 + + # Read metadata + requires_grad = struct.unpack(">?", data[offset : offset + 1])[0] + offset += 1 + device_len = struct.unpack(">H", data[offset : offset + 2])[0] + offset += 2 + device_str = data[offset : offset + device_len].decode("utf-8") + offset += device_len + + # Deserialize numpy array + arr, arr_bytes = deserialize_numpy(data[offset:]) + offset += arr_bytes + + # Convert to tensor + tensor = torch.from_numpy(arr) + + # Restore device (only if available) + if device_str.startswith("cuda") and torch.cuda.is_available(): + tensor = tensor.to(device_str) + + if requires_grad: + tensor.requires_grad_(True) + + return tensor, offset + + +def serialize(value: Any) -> bytes: + """ + Serialize any supported value to bytes. + + Supported types: + - None, int, float, str, bool + - bytes + - list, dict + - numpy.ndarray + - torch.Tensor + """ + if value is None: + return struct.pack(">B", DataType.NONE) + + if isinstance(value, np.ndarray): + type_byte = struct.pack(">B", DataType.NUMPY) + return type_byte + serialize_numpy(value) + + if TORCH_AVAILABLE and isinstance(value, torch.Tensor): + type_byte = struct.pack(">B", DataType.TORCH) + return type_byte + serialize_torch(value) + + if isinstance(value, bytes): + type_byte = struct.pack(">B", DataType.BYTES) + length = struct.pack(">I", len(value)) + return type_byte + length + value + + if isinstance(value, dict): + type_byte = struct.pack(">B", DataType.DICT) + # Recursively serialize dict values + serialized_dict = {} + for k, v in value.items(): + serialized_dict[k] = serialize(v) + packed = msgpack.packb(serialized_dict, use_bin_type=True) + return type_byte + struct.pack(">I", len(packed)) + packed + + if isinstance(value, (list, tuple)): + type_byte = struct.pack(">B", DataType.LIST) + # Recursively serialize list items + serialized_list = [serialize(item) for item in value] + packed = msgpack.packb(serialized_list, use_bin_type=True) + return type_byte + struct.pack(">I", len(packed)) + packed + + # Primitive types: use msgpack + type_byte = struct.pack(">B", DataType.PRIMITIVE) + packed = msgpack.packb(value, use_bin_type=True) + return type_byte + struct.pack(">I", len(packed)) + packed + + +def deserialize(data: bytes) -> tuple[Any, int]: + """ + Deserialize bytes to a value. + + Returns: + Tuple of (value, bytes_consumed) + """ + offset = 0 + data_type = DataType(struct.unpack(">B", data[offset : offset + 1])[0]) + offset += 1 + + if data_type == DataType.NONE: + return None, offset + + if data_type == DataType.NUMPY: + arr, arr_bytes = deserialize_numpy(data[offset:]) + return arr, offset + arr_bytes + + if data_type == DataType.TORCH: + tensor, tensor_bytes = deserialize_torch(data[offset:]) + return tensor, offset + tensor_bytes + + if data_type == DataType.BYTES: + length = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + return data[offset : offset + length], offset + length + + if data_type == DataType.DICT: + length = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + packed = data[offset : offset + length] + offset += length + + serialized_dict = msgpack.unpackb(packed, raw=False) + result = {} + for k, v in serialized_dict.items(): + result[k], _ = deserialize(v) + return result, offset + + if data_type == DataType.LIST: + length = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + packed = data[offset : offset + length] + offset += length + + serialized_list = msgpack.unpackb(packed, raw=False) + result = [] + for item in serialized_list: + val, _ = deserialize(item) + result.append(val) + return result, offset + + if data_type == DataType.PRIMITIVE: + length = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + packed = data[offset : offset + length] + offset += length + return msgpack.unpackb(packed, raw=False), offset + + raise ValueError(f"Unknown data type: {data_type}") + + +def serialize_message_data(fields: dict[str, Any]) -> bytes: + """ + Serialize message fields to bytes. + + Format: + - 2 bytes: number of fields + - For each field: + - 2 bytes: key length + - key bytes + - 4 bytes: value length + - value bytes + """ + parts = [struct.pack(">H", len(fields))] + + for key, value in fields.items(): + key_bytes = key.encode("utf-8") + value_bytes = serialize(value) + + parts.append(struct.pack(">H", len(key_bytes))) + parts.append(key_bytes) + parts.append(struct.pack(">I", len(value_bytes))) + parts.append(value_bytes) + + return b"".join(parts) + + +def deserialize_message_data(data: bytes) -> dict[str, Any]: + """Deserialize bytes to message fields.""" + offset = 0 + num_fields = struct.unpack(">H", data[offset : offset + 2])[0] + offset += 2 + + fields = {} + for _ in range(num_fields): + key_len = struct.unpack(">H", data[offset : offset + 2])[0] + offset += 2 + key = data[offset : offset + key_len].decode("utf-8") + offset += key_len + + value_len = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + value_bytes = data[offset : offset + value_len] + offset += value_len + + value, _ = deserialize(value_bytes) + fields[key] = value + + return fields diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e319e5a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for Cortex framework.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..81b7946 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +""" +Pytest fixtures for Cortex tests. +""" + +import contextlib +import os +import threading +import time +from collections.abc import Generator + +import pytest +import zmq + + +@pytest.fixture +def zmq_context() -> Generator[zmq.Context, None, None]: + """Provide a ZMQ context for tests.""" + ctx = zmq.Context() + yield ctx + ctx.term() + + +@pytest.fixture +def temp_ipc_address(tmp_path) -> str: + """Provide a temporary IPC address.""" + return f"ipc://{tmp_path}/test_socket" + + +@pytest.fixture +def discovery_address(tmp_path) -> str: + """Provide a discovery daemon address.""" + return f"ipc://{tmp_path}/cortex_discovery" + + +@pytest.fixture +def cleanup_cortex_temp(): + """Clean up Cortex temporary files after tests.""" + yield + # Cleanup + import shutil + + cortex_dir = "/tmp/cortex" + if os.path.exists(cortex_dir): + with contextlib.suppress(Exception): + shutil.rmtree(cortex_dir) + + +class DiscoveryDaemonFixture: + """ + Test fixture for running a discovery daemon in a background thread. + """ + + def __init__(self, address: str): + self.address = address + self._daemon = None + self._thread = None + self._started = threading.Event() + + def start(self) -> None: + """Start the discovery daemon.""" + from cortex.discovery.daemon import DiscoveryDaemon + + self._daemon = DiscoveryDaemon(address=self.address) + + def run_daemon(): + self._started.set() + with contextlib.suppress(Exception): + self._daemon.start() # Daemon was stopped + + self._thread = threading.Thread(target=run_daemon, daemon=True) + self._thread.start() + + # Wait for daemon to start + self._started.wait(timeout=5.0) + time.sleep(0.1) # Extra time for socket binding + + def stop(self) -> None: + """Stop the discovery daemon.""" + if self._daemon: + with contextlib.suppress(Exception): + self._daemon.stop() + if self._thread: + self._thread.join(timeout=1.0) + self._daemon = None + self._thread = None + + +@pytest.fixture +def discovery_daemon( + discovery_address: str, +) -> Generator[DiscoveryDaemonFixture, None, None]: + """Provide a running discovery daemon.""" + fixture = DiscoveryDaemonFixture(discovery_address) + fixture.start() + yield fixture + fixture.stop() diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..e0f2eb8 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,221 @@ +""" +Tests for the discovery system. +""" + +import threading +import time + +from cortex.discovery.client import DiscoveryClient +from cortex.discovery.daemon import DiscoveryDaemon +from cortex.discovery.protocol import ( + DiscoveryCommand, + DiscoveryRequest, + DiscoveryResponse, + DiscoveryStatus, + TopicInfo, +) + + +class TestTopicInfo: + """Tests for TopicInfo serialization.""" + + def test_topic_info_roundtrip(self): + """TopicInfo should serialize and deserialize.""" + info = TopicInfo( + name="/camera/image", + address="ipc:///tmp/test_socket", + message_type="ImageMessage", + fingerprint=0x123456789ABCDEF0, + publisher_node="camera_node", + ) + + data = info.to_bytes() + restored = TopicInfo.from_bytes(data) + + assert restored.name == info.name + assert restored.address == info.address + assert restored.message_type == info.message_type + assert restored.fingerprint == info.fingerprint + assert restored.publisher_node == info.publisher_node + + +class TestDiscoveryProtocol: + """Tests for discovery protocol messages.""" + + def test_request_roundtrip(self): + """DiscoveryRequest should serialize correctly.""" + request = DiscoveryRequest( + command=DiscoveryCommand.REGISTER_TOPIC, + topic_info=TopicInfo( + name="/test", + address="ipc:///tmp/test", + message_type="TestMsg", + fingerprint=12345, + publisher_node="test_node", + ), + ) + + data = request.to_bytes() + restored = DiscoveryRequest.from_bytes(data) + + assert restored.command == request.command + assert restored.topic_info.name == request.topic_info.name + + def test_response_roundtrip(self): + """DiscoveryResponse should serialize correctly.""" + response = DiscoveryResponse( + status=DiscoveryStatus.OK, + message="Success", + topic_info=TopicInfo( + name="/test", + address="ipc:///tmp/test", + message_type="TestMsg", + fingerprint=12345, + publisher_node="test_node", + ), + ) + + data = response.to_bytes() + restored = DiscoveryResponse.from_bytes(data) + + assert restored.status == response.status + assert restored.message == response.message + assert restored.topic_info.name == response.topic_info.name + + +class TestDiscoveryDaemon: + """Tests for discovery daemon.""" + + def test_daemon_starts_and_stops(self, discovery_address): + """Daemon should start and stop cleanly.""" + daemon = DiscoveryDaemon(address=discovery_address) + + # Start in background thread + thread = threading.Thread(target=daemon.start, daemon=True) + thread.start() + + time.sleep(0.2) # Let it start + + daemon.stop() + thread.join(timeout=2.0) + + assert not thread.is_alive() + + +class TestDiscoveryClient: + """Tests for discovery client.""" + + def test_register_and_lookup(self, discovery_daemon, discovery_address): + """Client should register and lookup topics.""" + client = DiscoveryClient(discovery_address=discovery_address) + + topic_info = TopicInfo( + name="/test/topic", + address="ipc:///tmp/test_topic", + message_type="TestMessage", + fingerprint=12345, + publisher_node="test_node", + ) + + # Register + success = client.register_topic(topic_info) + assert success + + # Lookup + found = client.lookup_topic("/test/topic") + assert found is not None + assert found.name == "/test/topic" + assert found.address == "ipc:///tmp/test_topic" + + client.close() + + def test_lookup_nonexistent(self, discovery_daemon, discovery_address): + """Lookup of nonexistent topic should return None.""" + client = DiscoveryClient(discovery_address=discovery_address) + + found = client.lookup_topic("/nonexistent/topic") + assert found is None + + client.close() + + def test_list_topics(self, discovery_daemon, discovery_address): + """Client should list all topics.""" + client = DiscoveryClient(discovery_address=discovery_address) + + # Register some topics + for i in range(3): + info = TopicInfo( + name=f"/test/topic_{i}", + address=f"ipc:///tmp/topic_{i}", + message_type="TestMessage", + fingerprint=i, + publisher_node="test_node", + ) + client.register_topic(info) + + # List + topics = client.list_topics() + assert len(topics) >= 3 + + names = [t.name for t in topics] + assert "/test/topic_0" in names + assert "/test/topic_1" in names + assert "/test/topic_2" in names + + client.close() + + def test_unregister(self, discovery_daemon, discovery_address): + """Client should unregister topics.""" + client = DiscoveryClient(discovery_address=discovery_address) + + info = TopicInfo( + name="/test/unregister", + address="ipc:///tmp/unregister", + message_type="TestMessage", + fingerprint=99999, + publisher_node="test_node", + ) + + # Register + client.register_topic(info) + assert client.lookup_topic("/test/unregister") is not None + + # Unregister + client.unregister_topic("/test/unregister") + + # Should be gone + assert client.lookup_topic("/test/unregister") is None + + client.close() + + def test_wait_for_topic(self, discovery_daemon, discovery_address): + """Client should wait for topic to appear.""" + client = DiscoveryClient(discovery_address=discovery_address) + + # Register topic after delay + def delayed_register(): + time.sleep(0.5) + client2 = DiscoveryClient(discovery_address=discovery_address) + info = TopicInfo( + name="/delayed/topic", + address="ipc:///tmp/delayed", + message_type="TestMessage", + fingerprint=888, + publisher_node="delayed_node", + ) + client2.register_topic(info) + client2.close() + + thread = threading.Thread(target=delayed_register, daemon=True) + thread.start() + + # Wait for topic + start = time.time() + found = client.wait_for_topic("/delayed/topic", timeout=5.0, poll_interval=0.1) + elapsed = time.time() - start + + assert found is not None + assert found.name == "/delayed/topic" + assert elapsed >= 0.4 # Should have waited + + client.close() diff --git a/tests/test_hashing.py b/tests/test_hashing.py new file mode 100644 index 0000000..301af5f --- /dev/null +++ b/tests/test_hashing.py @@ -0,0 +1,107 @@ +""" +Tests for the hashing utilities. +""" + +from dataclasses import dataclass + +from cortex.messages.base import Message +from cortex.utils.hashing import ( + bytes_to_fingerprint, + clear_fingerprint_cache, + compute_fingerprint, + fingerprint_to_bytes, + get_cached_fingerprint, +) + + +class TestFingerprint: + """Tests for fingerprint computation.""" + + def setup_method(self): + """Clear cache before each test.""" + clear_fingerprint_cache() + + def test_fingerprint_is_64_bit(self): + """Fingerprint should be a 64-bit integer.""" + + @dataclass + class TestMsg(Message): + value: int + + fp = compute_fingerprint(TestMsg) + assert isinstance(fp, int) + assert 0 <= fp < 2**64 + + def test_fingerprint_consistency(self): + """Same class should always produce same fingerprint.""" + + @dataclass + class TestMsg(Message): + value: int + + fp1 = compute_fingerprint(TestMsg) + fp2 = compute_fingerprint(TestMsg) + assert fp1 == fp2 + + def test_different_classes_different_fingerprints(self): + """Different classes should have different fingerprints.""" + + @dataclass + class MsgA(Message): + value: int + + @dataclass + class MsgB(Message): + value: int + + fp_a = compute_fingerprint(MsgA) + fp_b = compute_fingerprint(MsgB) + assert fp_a != fp_b + + def test_different_fields_different_fingerprints(self): + """Classes with different fields should have different fingerprints.""" + + @dataclass + class MsgInt(Message): + value: int + + @dataclass + class MsgStr(Message): + value: str + + fp_int = compute_fingerprint(MsgInt) + fp_str = compute_fingerprint(MsgStr) + assert fp_int != fp_str + + def test_fingerprint_to_bytes_roundtrip(self): + """Fingerprint should survive bytes conversion.""" + original = 0x123456789ABCDEF0 + + as_bytes = fingerprint_to_bytes(original) + assert len(as_bytes) == 8 + + restored = bytes_to_fingerprint(as_bytes) + assert restored == original + + def test_cached_fingerprint(self): + """Cached fingerprint should return same value.""" + + @dataclass + class CachedMsg(Message): + data: str + + fp1 = get_cached_fingerprint(CachedMsg) + fp2 = get_cached_fingerprint(CachedMsg) + assert fp1 == fp2 + + def test_clear_cache(self): + """Cache clearing should work.""" + from cortex.messages.standard import StringMessage + + # Use a stable message class (not defined inline) + fp1 = get_cached_fingerprint(StringMessage) + clear_fingerprint_cache() + fp2 = get_cached_fingerprint(StringMessage) + + # Should be same value but computed fresh + assert fp1 == fp2 diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..bcc08fb --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,235 @@ +""" +Tests for the message system. +""" + +from dataclasses import dataclass + +import numpy as np + +from cortex.messages.base import Message, MessageHeader, MessageType +from cortex.messages.standard import ( + ArrayMessage, + DictMessage, + FloatMessage, + ImageMessage, + IntMessage, + PointCloudMessage, + StringMessage, +) +from cortex.utils.hashing import clear_fingerprint_cache + + +class TestMessageBase: + """Tests for base Message class.""" + + def setup_method(self): + """Clear registries before each test.""" + clear_fingerprint_cache() + MessageType.clear() + + def test_message_fingerprint(self): + """Messages should have a fingerprint.""" + + @dataclass + class TestMsg(Message): + value: int + + fp = TestMsg.fingerprint() + assert isinstance(fp, int) + assert fp > 0 + + def test_message_auto_registration(self): + """Messages should auto-register with MessageType.""" + + @dataclass + class AutoRegMsg(Message): + data: str + + fp = AutoRegMsg.fingerprint() + registered = MessageType.get(fp) + assert registered is AutoRegMsg + + def test_message_to_bytes(self): + """Messages should serialize to bytes.""" + + @dataclass + class BytesMsg(Message): + value: int + name: str + + msg = BytesMsg(value=42, name="test") + data = msg.to_bytes() + + assert isinstance(data, bytes) + assert len(data) > MessageHeader.size() + + def test_message_from_bytes(self): + """Messages should deserialize from bytes.""" + + @dataclass + class RoundtripMsg(Message): + value: int + name: str + + original = RoundtripMsg(value=42, name="test") + data = original.to_bytes() + + restored, header = RoundtripMsg.from_bytes(data) + + assert restored.value == original.value + assert restored.name == original.name + assert header.fingerprint == RoundtripMsg.fingerprint() + + def test_message_decode(self): + """Messages should decode without knowing type.""" + + @dataclass + class DecodeMsg(Message): + data: int + + original = DecodeMsg(data=123) + data = original.to_bytes() + + restored, header = Message.decode(data) + + assert isinstance(restored, DecodeMsg) + assert restored.data == 123 + + def test_message_header_timestamp(self): + """Message header should have valid timestamp.""" + + @dataclass + class TimestampMsg(Message): + x: int + + msg = TimestampMsg(x=1) + data = msg.to_bytes() + + _, header = TimestampMsg.from_bytes(data) + + assert header.timestamp_ns > 0 + + def test_message_header_sequence(self): + """Message headers should have incrementing sequences.""" + + @dataclass + class SeqMsg(Message): + x: int + + msg1 = SeqMsg(x=1) + msg2 = SeqMsg(x=2) + + data1 = msg1.to_bytes() + data2 = msg2.to_bytes() + + _, header1 = SeqMsg.from_bytes(data1) + _, header2 = SeqMsg.from_bytes(data2) + + assert header2.sequence > header1.sequence + + +class TestStandardMessages: + """Tests for standard message types.""" + + def test_string_message(self): + """StringMessage should work.""" + msg = StringMessage(data="hello world") + data = msg.to_bytes() + restored, _ = StringMessage.from_bytes(data) + + assert restored.data == "hello world" + + def test_int_message(self): + """IntMessage should work.""" + msg = IntMessage(data=42) + data = msg.to_bytes() + restored, _ = IntMessage.from_bytes(data) + + assert restored.data == 42 + + def test_float_message(self): + """FloatMessage should work.""" + msg = FloatMessage(data=3.14159) + data = msg.to_bytes() + restored, _ = FloatMessage.from_bytes(data) + + assert abs(restored.data - 3.14159) < 1e-5 + + def test_dict_message(self): + """DictMessage should work.""" + msg = DictMessage( + data={"key1": "value1", "key2": 42, "nested": {"inner": True}} + ) + data = msg.to_bytes() + restored, _ = DictMessage.from_bytes(data) + + assert restored.data["key1"] == "value1" + assert restored.data["key2"] == 42 + assert restored.data["nested"]["inner"] + + def test_array_message(self): + """ArrayMessage should work.""" + arr = np.random.randn(100, 100).astype(np.float32) + msg = ArrayMessage(data=arr, name="test_array", frame_id="world") + + data = msg.to_bytes() + restored, _ = ArrayMessage.from_bytes(data) + + np.testing.assert_array_almost_equal(arr, restored.data) + assert restored.name == "test_array" + assert restored.frame_id == "world" + + def test_image_message(self): + """ImageMessage should work.""" + img = np.random.randint(0, 256, (480, 640, 3), dtype=np.uint8) + msg = ImageMessage(data=img, encoding="rgb8") + + data = msg.to_bytes() + restored, _ = ImageMessage.from_bytes(data) + + np.testing.assert_array_equal(img, restored.data) + assert restored.encoding == "rgb8" + assert restored.width == 640 + assert restored.height == 480 + + def test_point_cloud_message(self): + """PointCloudMessage should work.""" + points = np.random.randn(1000, 3).astype(np.float32) + colors = np.random.randint(0, 256, (1000, 3), dtype=np.uint8) + + msg = PointCloudMessage(points=points, colors=colors, frame_id="lidar") + + data = msg.to_bytes() + restored, _ = PointCloudMessage.from_bytes(data) + + np.testing.assert_array_almost_equal(points, restored.points) + np.testing.assert_array_equal(colors, restored.colors) + assert restored.frame_id == "lidar" + + +class TestMessagePerformance: + """Performance tests for message serialization.""" + + def test_large_array_serialization(self): + """Large arrays should serialize efficiently.""" + import time + + # 1 megabyte array + arr = np.random.randn(1024, 1024).astype(np.float32) + msg = ArrayMessage(data=arr) + + # Serialize + start = time.time() + data = msg.to_bytes() + serialize_time = time.time() - start + + # Deserialize + start = time.time() + restored, _ = ArrayMessage.from_bytes(data) + deserialize_time = time.time() - start + + # Should be reasonably fast (< 100ms for 4MB) + assert serialize_time < 0.1, f"Serialize too slow: {serialize_time:.3f}s" + assert deserialize_time < 0.1, f"Deserialize too slow: {deserialize_time:.3f}s" + + np.testing.assert_array_almost_equal(arr, restored.data) diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..f31c63f --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,251 @@ +""" +Tests for Node class. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +from dataclasses import dataclass + +import pytest + +from cortex.core.node import Node +from cortex.messages.base import Message, MessageHeader +from cortex.messages.standard import IntMessage + + +@dataclass +class SensorData(Message): + """Test sensor data message.""" + + timestamp: float + value: float + sensor_id: str + + +class TestNode: + """Tests for Node class.""" + + @pytest.mark.asyncio + async def test_node_creation(self, discovery_daemon, discovery_address): + """Node should create successfully.""" + node = Node(name="test_node", discovery_address=discovery_address) + + assert node.name == "test_node" + assert len(node.publishers) == 0 + assert len(node.subscribers) == 0 + + await node.close() + + @pytest.mark.asyncio + async def test_create_publisher(self, discovery_daemon, discovery_address): + """Node should create publishers.""" + node = Node(name="pub_node", discovery_address=discovery_address) + + pub = node.create_publisher( + topic_name="/sensor/data", + message_type=SensorData, + ) + + assert pub is not None + assert "/sensor/data" in node.publishers + + # Duplicate should return same publisher + pub2 = node.create_publisher( + topic_name="/sensor/data", + message_type=SensorData, + ) + assert pub is pub2 + + await node.close() + + @pytest.mark.asyncio + async def test_create_subscriber(self, discovery_daemon, discovery_address): + """Node should create subscribers.""" + # First create a publisher + pub_node = Node(name="pub_node", discovery_address=discovery_address) + pub_node.create_publisher("/test/sub", SensorData) + await asyncio.sleep(0.2) + + # Now create subscriber + sub_node = Node(name="sub_node", discovery_address=discovery_address) + + received: list[SensorData] = [] + + async def callback(msg: SensorData, header: MessageHeader) -> None: + received.append(msg) + + sub = sub_node.create_subscriber( + topic_name="/test/sub", + message_type=SensorData, + callback=callback, + ) + + assert sub is not None + assert "/test/sub" in sub_node.subscribers + + await pub_node.close() + await sub_node.close() + + @pytest.mark.asyncio + async def test_node_pubsub_communication(self, discovery_daemon, discovery_address): + """Nodes should communicate via pub/sub.""" + # Publisher node + pub_node = Node(name="sensor_node", discovery_address=discovery_address) + pub = pub_node.create_publisher("/sensor/data", SensorData) + await asyncio.sleep(0.2) + + # Subscriber node + sub_node = Node(name="processor_node", discovery_address=discovery_address) + + received: list[SensorData] = [] + event = asyncio.Event() + + async def callback(msg: SensorData, header: MessageHeader) -> None: + received.append(msg) + event.set() + + sub_node.create_subscriber("/sensor/data", SensorData, callback) + await asyncio.sleep(0.2) + + # Start subscriber in background + run_task = asyncio.create_task(sub_node.run()) + + # Publish data + pub.publish(SensorData(timestamp=time.time(), value=42.0, sensor_id="sensor_1")) + + # Wait for message to be received + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(event.wait(), timeout=2.0) + + # Cancel the run task + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert len(received) == 1 + assert received[0].value == 42.0 + assert received[0].sensor_id == "sensor_1" + + await pub_node.close() + await sub_node.close() + + @pytest.mark.asyncio + async def test_node_timer(self, discovery_daemon, discovery_address): + """Node timers should fire.""" + node = Node(name="timer_node", discovery_address=discovery_address) + + fired: list[float] = [] + + async def timer_callback() -> None: + fired.append(time.time()) + + node.create_timer(0.1, timer_callback) # 10 Hz + + # Run node for a short time + run_task = asyncio.create_task(node.run()) + + # Wait for timer to fire multiple times + await asyncio.sleep(0.35) + + # Cancel and clean up + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + await node.close() + + assert len(fired) >= 2 # Should fire at least 2-3 times in 350ms + + @pytest.mark.asyncio + async def test_node_spin_once(self, discovery_daemon, discovery_address): + """Node should receive messages via async run.""" + # Setup + pub_node = Node(name="pub", discovery_address=discovery_address) + pub = pub_node.create_publisher("/spin_test", IntMessage) + await asyncio.sleep(0.2) + + sub_node = Node(name="sub", discovery_address=discovery_address) + received: list[IntMessage] = [] + event = asyncio.Event() + + async def callback(msg: IntMessage, header: MessageHeader) -> None: + received.append(msg) + event.set() + + sub_node.create_subscriber("/spin_test", IntMessage, callback) + await asyncio.sleep(0.2) + + # Start subscriber + run_task = asyncio.create_task(sub_node.run()) + + # Publish + pub.publish(IntMessage(data=123)) + + # Wait for message + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(event.wait(), timeout=2.0) + + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert len(received) == 1 + assert received[0].data == 123 + + await pub_node.close() + await sub_node.close() + + +class TestNodeLifecycle: + """Tests for node lifecycle management.""" + + @pytest.mark.asyncio + async def test_node_stop(self, discovery_daemon, discovery_address): + """Node stop should work correctly via task cancellation.""" + node = Node(name="stop_test", discovery_address=discovery_address) + + # Start running + run_task = asyncio.create_task(node.run()) + + await asyncio.sleep(0.1) + + # Cancel the task (equivalent to stop) + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + await node.close() + + @pytest.mark.asyncio + async def test_node_destroy_cleans_up(self, discovery_daemon, discovery_address): + """Node close should clean up resources.""" + node = Node(name="destroy_test", discovery_address=discovery_address) + + # Create some publishers + node.create_publisher("/test1", IntMessage) + node.create_publisher("/test2", IntMessage) + + assert len(node.publishers) == 2 + + # Close + await node.close() + + assert len(node.publishers) == 0 + assert len(node.subscribers) == 0 + + @pytest.mark.asyncio + async def test_node_async_context_manager( + self, discovery_daemon, discovery_address + ): + """Node should work as async context manager.""" + async with Node( + name="context_node", discovery_address=discovery_address + ) as node: + pub = node.create_publisher("/context_test", IntMessage) + assert pub is not None + + # After exiting, node should be closed + # (hard to verify, but at least no exceptions) diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py new file mode 100644 index 0000000..0e7faa9 --- /dev/null +++ b/tests/test_pubsub.py @@ -0,0 +1,386 @@ +""" +Tests for publisher and subscriber. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import threading +import time +from dataclasses import dataclass + +import numpy as np +import pytest + +from cortex.core.publisher import Publisher +from cortex.core.subscriber import Subscriber +from cortex.messages.base import Message, MessageHeader +from cortex.messages.standard import ArrayMessage, DictMessage, StringMessage + + +@dataclass +class SampleMessage(Message): + """Simple test message.""" + + value: int + name: str + + +class TestPublisher: + """Tests for Publisher class.""" + + def test_publisher_creates_socket( + self, discovery_daemon, discovery_address, tmp_path + ): + """Publisher should create IPC socket.""" + pub = Publisher( + topic_name="/test/publisher", + message_type=SampleMessage, + node_name="test_node", + discovery_address=discovery_address, + ) + + assert pub.address.startswith("ipc://") + + pub.close() + + def test_publisher_registers_with_discovery( + self, discovery_daemon, discovery_address + ): + """Publisher should register with discovery daemon.""" + pub = Publisher( + topic_name="/test/registered", + message_type=SampleMessage, + node_name="test_node", + discovery_address=discovery_address, + ) + + # Give time for registration + time.sleep(0.2) + + assert pub.is_registered + + pub.close() + + def test_publisher_publishes_messages(self, discovery_daemon, discovery_address): + """Publisher should publish messages.""" + pub = Publisher( + topic_name="/test/publish", + message_type=SampleMessage, + node_name="test_node", + discovery_address=discovery_address, + ) + + msg = SampleMessage(value=42, name="test") + success = pub.publish(msg) + + assert success + assert pub.publish_count == 1 + + pub.close() + + def test_publisher_type_checking(self, discovery_daemon, discovery_address): + """Publisher should reject wrong message types.""" + pub = Publisher( + topic_name="/test/typecheck", + message_type=SampleMessage, + node_name="test_node", + discovery_address=discovery_address, + ) + + wrong_msg = StringMessage(data="wrong type") + + with pytest.raises(TypeError): + pub.publish(wrong_msg) + + pub.close() + + +class TestSubscriber: + """Tests for Subscriber class.""" + + def test_subscriber_connects_to_publisher( + self, discovery_daemon, discovery_address + ): + """Subscriber should connect to existing publisher.""" + # Create publisher first + pub = Publisher( + topic_name="/test/sub_connect", + message_type=SampleMessage, + node_name="pub_node", + discovery_address=discovery_address, + ) + time.sleep(0.2) # Let it register + + # Create subscriber + sub = Subscriber( + topic_name="/test/sub_connect", + message_type=SampleMessage, + node_name="sub_node", + discovery_address=discovery_address, + wait_for_topic=True, + topic_timeout=5.0, + ) + + assert sub.is_connected + assert sub.topic_info is not None + assert sub.topic_info.address == pub.address + + sub.close() + pub.close() + + @pytest.mark.asyncio + async def test_subscriber_receives_messages( + self, discovery_daemon, discovery_address + ): + """Subscriber should receive published messages.""" + # Create publisher + pub = Publisher( + topic_name="/test/sub_recv", + message_type=SampleMessage, + node_name="pub_node", + discovery_address=discovery_address, + ) + await asyncio.sleep(0.2) + + # Create subscriber + received: list[SampleMessage] = [] + event = asyncio.Event() + + async def callback(msg: SampleMessage, header: MessageHeader) -> None: + received.append(msg) + event.set() + + sub = Subscriber( + topic_name="/test/sub_recv", + message_type=SampleMessage, + callback=callback, + node_name="sub_node", + discovery_address=discovery_address, + ) + + # Need small delay for ZMQ connection + await asyncio.sleep(0.2) + + # Start subscriber in background + run_task = asyncio.create_task(sub.run()) + + # Publish message + pub.publish(SampleMessage(value=42, name="test")) + + # Wait for message to be received + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(event.wait(), timeout=2.0) + + # Stop subscriber + sub.stop() + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert len(received) == 1 + assert received[0].value == 42 + assert received[0].name == "test" + + sub.close() + pub.close() + + def test_subscriber_waits_for_topic(self, discovery_daemon, discovery_address): + """Subscriber should wait for topic to appear.""" + received = [] + + # Create subscriber first (will wait) + def create_subscriber(): + sub = Subscriber( + topic_name="/test/wait_topic", + message_type=SampleMessage, + node_name="sub_node", + discovery_address=discovery_address, + wait_for_topic=True, + topic_timeout=5.0, + ) + received.append(sub.is_connected) + sub.close() + + sub_thread = threading.Thread(target=create_subscriber, daemon=True) + sub_thread.start() + + # Create publisher after delay + time.sleep(0.5) + pub = Publisher( + topic_name="/test/wait_topic", + message_type=SampleMessage, + node_name="pub_node", + discovery_address=discovery_address, + ) + + sub_thread.join(timeout=6.0) + + assert len(received) == 1 + assert received[0] + + pub.close() + + +class TestPubSubIntegration: + """Integration tests for pub/sub.""" + + @pytest.mark.asyncio + async def test_multiple_messages(self, discovery_daemon, discovery_address): + """Multiple messages should be received in order.""" + pub = Publisher( + topic_name="/test/multi_msg", + message_type=SampleMessage, + node_name="pub_node", + discovery_address=discovery_address, + ) + await asyncio.sleep(0.2) + + received: list[int] = [] + done_event = asyncio.Event() + + async def callback(msg: SampleMessage, header: MessageHeader) -> None: + received.append(msg.value) + if len(received) >= 10: + done_event.set() + + sub = Subscriber( + topic_name="/test/multi_msg", + message_type=SampleMessage, + callback=callback, + node_name="sub_node", + discovery_address=discovery_address, + ) + await asyncio.sleep(0.2) + + # Start subscriber in background + run_task = asyncio.create_task(sub.run()) + + # Publish multiple messages + for i in range(10): + pub.publish(SampleMessage(value=i, name=f"msg_{i}")) + + # Wait for all messages + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(done_event.wait(), timeout=5.0) + + # Stop subscriber + sub.stop() + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert len(received) == 10 + assert received == list(range(10)) + + sub.close() + pub.close() + + @pytest.mark.asyncio + async def test_numpy_array_transfer(self, discovery_daemon, discovery_address): + """NumPy arrays should transfer correctly.""" + pub = Publisher( + topic_name="/test/numpy", + message_type=ArrayMessage, + node_name="pub_node", + discovery_address=discovery_address, + ) + await asyncio.sleep(0.2) + + received: list[np.ndarray] = [] + event = asyncio.Event() + + async def callback(msg: ArrayMessage, header: MessageHeader) -> None: + received.append(msg.data.copy()) + event.set() + + sub = Subscriber( + topic_name="/test/numpy", + message_type=ArrayMessage, + callback=callback, + node_name="sub_node", + discovery_address=discovery_address, + ) + await asyncio.sleep(0.2) + + # Start subscriber + run_task = asyncio.create_task(sub.run()) + + # Send large array + arr = np.random.randn(100, 100).astype(np.float32) + pub.publish(ArrayMessage(data=arr)) + + # Wait for message + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(event.wait(), timeout=2.0) + + # Stop + sub.stop() + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert len(received) == 1 + np.testing.assert_array_almost_equal(arr, received[0]) + + sub.close() + pub.close() + + @pytest.mark.asyncio + async def test_dict_message_transfer(self, discovery_daemon, discovery_address): + """Dict messages should transfer correctly.""" + pub = Publisher( + topic_name="/test/dict", + message_type=DictMessage, + node_name="pub_node", + discovery_address=discovery_address, + ) + await asyncio.sleep(0.2) + + received: list[dict] = [] + event = asyncio.Event() + + async def callback(msg: DictMessage, header: MessageHeader) -> None: + received.append(msg.data) + event.set() + + sub = Subscriber( + topic_name="/test/dict", + message_type=DictMessage, + callback=callback, + node_name="sub_node", + discovery_address=discovery_address, + ) + await asyncio.sleep(0.2) + + # Start subscriber + run_task = asyncio.create_task(sub.run()) + + # Send dict with nested structure + data = { + "name": "test", + "values": [1, 2, 3], + "nested": {"a": 1, "b": 2}, + "array": np.array([1.0, 2.0, 3.0]), + } + pub.publish(DictMessage(data=data)) + + # Wait for message + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(event.wait(), timeout=2.0) + + # Stop + sub.stop() + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert len(received) == 1 + assert received[0]["name"] == "test" + assert received[0]["values"] == [1, 2, 3] + assert received[0]["nested"]["a"] == 1 + np.testing.assert_array_almost_equal(data["array"], received[0]["array"]) + + sub.close() + pub.close() diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..6f3346f --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,251 @@ +""" +Tests for serialization utilities. +""" + +import numpy as np +import pytest + +from cortex.utils.serialization import ( + TORCH_AVAILABLE, + deserialize, + deserialize_message_data, + deserialize_numpy, + serialize, + serialize_message_data, + serialize_numpy, +) + +if TORCH_AVAILABLE: + import torch + + +class TestNumpySerialization: + """Tests for NumPy array serialization.""" + + def test_serialize_1d_array(self): + """1D arrays should serialize/deserialize correctly.""" + arr = np.array([1, 2, 3, 4, 5], dtype=np.int32) + + data = serialize_numpy(arr) + restored, _ = deserialize_numpy(data) + + np.testing.assert_array_equal(arr, restored) + assert arr.dtype == restored.dtype + + def test_serialize_2d_array(self): + """2D arrays should serialize/deserialize correctly.""" + arr = np.random.randn(10, 20).astype(np.float32) + + data = serialize_numpy(arr) + restored, _ = deserialize_numpy(data) + + np.testing.assert_array_almost_equal(arr, restored) + assert arr.dtype == restored.dtype + assert arr.shape == restored.shape + + def test_serialize_3d_array(self): + """3D arrays (like images) should work.""" + arr = np.random.randint(0, 256, (480, 640, 3), dtype=np.uint8) + + data = serialize_numpy(arr) + restored, _ = deserialize_numpy(data) + + np.testing.assert_array_equal(arr, restored) + + def test_various_dtypes(self): + """Various numpy dtypes should work.""" + dtypes = [ + np.float32, + np.float64, + np.int8, + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.bool_, + ] + + for dtype in dtypes: + arr = np.array([1, 2, 3], dtype=dtype) + data = serialize_numpy(arr) + restored, _ = deserialize_numpy(data) + + np.testing.assert_array_equal(arr, restored) + assert arr.dtype == restored.dtype, f"dtype mismatch for {dtype}" + + +class TestGenericSerialization: + """Tests for generic serialize/deserialize functions.""" + + def test_serialize_none(self): + """None should serialize correctly.""" + data = serialize(None) + restored, _ = deserialize(data) + assert restored is None + + def test_serialize_int(self): + """Integers should serialize correctly.""" + for val in [0, 1, -1, 2**31, -(2**31), 2**63 - 1]: + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_float(self): + """Floats should serialize correctly.""" + for val in [0.0, 1.5, -1.5, 3.14159, float("inf"), float("-inf")]: + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val or (np.isnan(val) and np.isnan(restored)) + + def test_serialize_string(self): + """Strings should serialize correctly.""" + for val in ["", "hello", "hello world", "🎉 unicode"]: + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_bool(self): + """Booleans should serialize correctly.""" + for val in [True, False]: + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_bytes(self): + """Bytes should serialize correctly.""" + val = b"\x00\x01\x02\xff" + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_list(self): + """Lists should serialize correctly.""" + val = [1, 2.0, "three", True, None] + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_nested_list(self): + """Nested lists should work.""" + val = [1, [2, [3, [4]]]] + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_dict(self): + """Dictionaries should serialize correctly.""" + val = {"a": 1, "b": 2.0, "c": "three"} + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_nested_dict(self): + """Nested dictionaries should work.""" + val = {"level1": {"level2": {"value": 42}}} + data = serialize(val) + restored, _ = deserialize(data) + assert restored == val + + def test_serialize_numpy_in_dict(self): + """NumPy arrays inside dicts should work.""" + arr = np.array([1, 2, 3], dtype=np.float32) + val = {"array": arr, "name": "test"} + + data = serialize(val) + restored, _ = deserialize(data) + + np.testing.assert_array_equal(arr, restored["array"]) + assert restored["name"] == "test" + + def test_serialize_numpy_via_generic(self): + """NumPy arrays via generic serialize should work.""" + arr = np.random.randn(5, 5).astype(np.float64) + + data = serialize(arr) + restored, _ = deserialize(data) + + np.testing.assert_array_almost_equal(arr, restored) + + +@pytest.mark.skipif(not TORCH_AVAILABLE, reason="PyTorch not available") +class TestTorchSerialization: + """Tests for PyTorch tensor serialization.""" + + def test_serialize_1d_tensor(self): + """1D tensors should serialize correctly.""" + tensor = torch.tensor([1.0, 2.0, 3.0]) + + data = serialize(tensor) + restored, _ = deserialize(data) + + torch.testing.assert_close(tensor, restored) + + def test_serialize_2d_tensor(self): + """2D tensors should serialize correctly.""" + tensor = torch.randn(10, 20) + + data = serialize(tensor) + restored, _ = deserialize(data) + + torch.testing.assert_close(tensor, restored) + + def test_tensor_in_dict(self): + """Tensors inside dicts should work.""" + tensor = torch.randn(5, 5) + val = {"tensor": tensor, "name": "test"} + + data = serialize(val) + restored, _ = deserialize(data) + + torch.testing.assert_close(tensor, restored["tensor"]) + assert restored["name"] == "test" + + +class TestMessageDataSerialization: + """Tests for message data serialization.""" + + def test_serialize_simple_fields(self): + """Simple field types should work.""" + fields = { + "int_field": 42, + "float_field": 3.14, + "str_field": "hello", + } + + data = serialize_message_data(fields) + restored = deserialize_message_data(data) + + assert restored == fields + + def test_serialize_array_fields(self): + """NumPy array fields should work.""" + fields = { + "data": np.random.randn(10, 10).astype(np.float32), + "name": "test_array", + } + + data = serialize_message_data(fields) + restored = deserialize_message_data(data) + + np.testing.assert_array_almost_equal(fields["data"], restored["data"]) + assert fields["name"] == restored["name"] + + def test_serialize_mixed_fields(self): + """Mixed field types should work.""" + fields = { + "array": np.array([1, 2, 3]), + "dict": {"nested": True}, + "list": [1, 2, 3], + "string": "test", + } + + data = serialize_message_data(fields) + restored = deserialize_message_data(data) + + np.testing.assert_array_equal(fields["array"], restored["array"]) + assert fields["dict"] == restored["dict"] + assert fields["list"] == restored["list"] + assert fields["string"] == restored["string"] From c80d0c137fd284ca89ec61d63b3623c41824e74a Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 15:13:57 -0500 Subject: [PATCH 02/28] update github action --- .github/workflows/lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5f5d4bd..110a253 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,9 @@ name: Lint on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] jobs: ruff: From b01d6787e1bb0ae9152a0be6a8357740e2b68037 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 15:45:01 -0500 Subject: [PATCH 03/28] separate executors, new github action to pytest --- .github/workflows/test.yml | 30 ++++++ benchmarks/bench_latency.py | 2 - benchmarks/bench_throughput.py | 2 - examples/multi_node_system.py | 2 - examples/publisher_dict.py | 6 +- examples/subscriber_dict.py | 2 - pyproject.toml | 4 +- src/cortex/__init__.py | 4 +- src/cortex/core/__init__.py | 6 +- src/cortex/core/executor.py | 175 +++++++++++-------------------- src/cortex/core/node.py | 30 ++---- src/cortex/core/publisher.py | 4 +- src/cortex/core/subscriber.py | 8 +- src/cortex/discovery/client.py | 4 +- src/cortex/discovery/daemon.py | 5 +- src/cortex/discovery/protocol.py | 9 +- src/cortex/messages/standard.py | 8 +- tests/test_node.py | 2 - tests/test_pubsub.py | 2 - 19 files changed, 128 insertions(+), 177 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c158050 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: pytest diff --git a/benchmarks/bench_latency.py b/benchmarks/bench_latency.py index a47bc53..e5fda5d 100644 --- a/benchmarks/bench_latency.py +++ b/benchmarks/bench_latency.py @@ -5,8 +5,6 @@ Measures round-trip latency between publisher and subscriber. """ -from __future__ import annotations - import argparse import asyncio import multiprocessing as mp diff --git a/benchmarks/bench_throughput.py b/benchmarks/bench_throughput.py index f894853..836552c 100644 --- a/benchmarks/bench_throughput.py +++ b/benchmarks/bench_throughput.py @@ -5,8 +5,6 @@ Measures maximum message throughput for different payload sizes. """ -from __future__ import annotations - import asyncio import builtins import contextlib diff --git a/examples/multi_node_system.py b/examples/multi_node_system.py index 3d44b79..8ab1d1f 100644 --- a/examples/multi_node_system.py +++ b/examples/multi_node_system.py @@ -16,8 +16,6 @@ python examples/multi_node_system.py """ -from __future__ import annotations - import asyncio import contextlib import time diff --git a/examples/publisher_dict.py b/examples/publisher_dict.py index c6ba422..1baf269 100644 --- a/examples/publisher_dict.py +++ b/examples/publisher_dict.py @@ -16,8 +16,6 @@ python examples/subscriber_dict.py """ -from __future__ import annotations - import asyncio import contextlib import time @@ -86,7 +84,9 @@ async def _publish_state(self) -> None: if self._count % 5 == 0: pos = state["pose"]["position"] - print(f"Published state {self._count}: pos=({pos['x']:.2f}, {pos['y']:.2f})") + print( + f"Published state {self._count}: pos=({pos['x']:.2f}, {pos['y']:.2f})" + ) self._count += 1 diff --git a/examples/subscriber_dict.py b/examples/subscriber_dict.py index ea364f4..9e2f27d 100644 --- a/examples/subscriber_dict.py +++ b/examples/subscriber_dict.py @@ -16,8 +16,6 @@ python examples/subscriber_dict.py """ -from __future__ import annotations - import asyncio import contextlib diff --git a/pyproject.toml b/pyproject.toml index e24c1b3..8b0ae76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "A lightweight framework using ZeroMQ for inter-process communication" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ {name = "Cortex Authors"} ] @@ -17,7 +17,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -59,6 +58,7 @@ asyncio_default_fixture_loop_scope = "function" [tool.ruff] line-length = 88 +target-version = "py310" [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"] diff --git a/src/cortex/__init__.py b/src/cortex/__init__.py index db2443a..fe6529c 100644 --- a/src/cortex/__init__.py +++ b/src/cortex/__init__.py @@ -9,7 +9,7 @@ - Asyncio-based architecture for cooperative multitasking """ -from cortex.core.executor import MultiRateExecutor, RateExecutor +from cortex.core.executor import AsyncExecutor, RateExecutor from cortex.core.node import Node from cortex.core.publisher import Publisher from cortex.core.subscriber import Subscriber @@ -28,8 +28,8 @@ "Node", "Publisher", "Subscriber", + "AsyncExecutor", "RateExecutor", - "MultiRateExecutor", "Message", "MessageType", "ArrayMessage", diff --git a/src/cortex/core/__init__.py b/src/cortex/core/__init__.py index 258a3b6..53d877a 100644 --- a/src/cortex/core/__init__.py +++ b/src/cortex/core/__init__.py @@ -1,7 +1,8 @@ """Core module for Cortex framework.""" from cortex.core.executor import ( - MultiRateExecutor, + AsyncExecutor, + BaseExecutor, RateExecutor, ) from cortex.core.node import Node @@ -13,6 +14,7 @@ "Publisher", "Subscriber", "MessageCallback", + "BaseExecutor", + "AsyncExecutor", "RateExecutor", - "MultiRateExecutor", ] diff --git a/src/cortex/core/executor.py b/src/cortex/core/executor.py index 634e131..d4d5f8d 100644 --- a/src/cortex/core/executor.py +++ b/src/cortex/core/executor.py @@ -1,15 +1,16 @@ """ Executor for managing async functions at constant rates. -Provides a utility for executing async callbacks with precise timing, +Provides utilities for executing async callbacks with precise timing, faithful to Python's cooperative multitasking model. """ import asyncio import logging import time -from collections.abc import Coroutine -from typing import Any, Callable, Optional +from abc import ABC, abstractmethod +from collections.abc import Callable, Coroutine +from typing import Any logger = logging.getLogger("cortex.executor") @@ -18,43 +19,23 @@ AsyncCallback = Callable[..., Coroutine[Any, Any, None]] -class RateExecutor: +class BaseExecutor(ABC): """ - Utility class for executing async functions at a constant rate. + Abstract base class for async executors. - Provides precise timing for periodic execution of async callbacks. - Uses cooperative multitasking - ideal for I/O-bound workloads in Python < 3.14. - - Example: - async def my_callback(): - print("tick") - - executor = RateExecutor(my_callback, rate_hz=10.0) - executor.start() - await executor.run() + Provides common interface for starting, stopping, and running + async callback functions. """ - def __init__( - self, - func: AsyncCallback, - rate_hz: Optional[float] = None, - ): + def __init__(self, func: AsyncCallback): """ - Initialize constant rate executor. + Initialize the executor. Args: func: Async function to execute - rate_hz: Target execution rate in Hz (None for as-fast-as-possible) """ self.func = func self._running = False - self._rate_hz = rate_hz - - if rate_hz is None: - self._run_impl = self._async_run - else: - self.interval = 1.0 / rate_hz - self._run_impl = self._constant_rate_run @property def running(self) -> bool: @@ -77,7 +58,29 @@ async def run(self, *args, **kwargs) -> None: finally: self.stop() - async def _async_run(self, *args, **kwargs) -> None: + @abstractmethod + async def _run_impl(self, *args, **kwargs) -> None: + """Implementation of the run loop. Subclasses must override.""" + ... + + +class AsyncExecutor(BaseExecutor): + """ + Executor that runs an async function as fast as possible. + + Yields to the event loop between executions to allow other + coroutines to run. + + Example: + async def process_data(): + data = await get_data() + await handle(data) + + executor = AsyncExecutor(process_data) + await executor.run() + """ + + async def _run_impl(self, *args, **kwargs) -> None: """Run the async function as fast as possible.""" while self._running: try: @@ -86,10 +89,38 @@ async def _async_run(self, *args, **kwargs) -> None: except asyncio.CancelledError: break except Exception as e: - logger.error(f"Error in _async_run: {e}") + logger.error(f"Error in AsyncExecutor: {e}") await asyncio.sleep(0.001) - async def _constant_rate_run(self, *args, **kwargs) -> None: + +class RateExecutor(BaseExecutor): + """ + Executor that runs an async function at a constant rate. + + Provides precise timing for periodic execution of async callbacks. + Uses cooperative multitasking - ideal for I/O-bound workloads. + + Example: + async def my_callback(): + print("tick") + + executor = RateExecutor(my_callback, rate_hz=10.0) + await executor.run() + """ + + def __init__(self, func: AsyncCallback, rate_hz: float): + """ + Initialize constant rate executor. + + Args: + func: Async function to execute + rate_hz: Target execution rate in Hz + """ + super().__init__(func) + self._rate_hz = rate_hz + self.interval = 1.0 / rate_hz + + async def _run_impl(self, *args, **kwargs) -> None: """ Run a function at constant rate with precise timing. @@ -113,7 +144,7 @@ async def _constant_rate_run(self, *args, **kwargs) -> None: except asyncio.CancelledError: break except Exception as e: - logger.error(f"Error in _constant_rate_run: {e}") + logger.error(f"Error in RateExecutor: {e}") await asyncio.sleep(0.001) # Sleep until next execution time (with small max to stay responsive) @@ -122,81 +153,3 @@ async def _constant_rate_run(self, *args, **kwargs) -> None: 0.001, # Max sleep to stay responsive ) await asyncio.sleep(sleep_time) - - -class MultiRateExecutor: - """ - Execute multiple async functions concurrently at different rates. - - Each function can have its own execution rate. - - Example: - executor = MultiRateExecutor() - executor.add(sensor_read, rate_hz=100.0) - executor.add(process_data, rate_hz=30.0) - executor.add(publish_results, rate_hz=10.0) - - await executor.run() - """ - - def __init__(self): - """Initialize multi-rate executor.""" - self._executors: list[RateExecutor] = [] - self._tasks: list[asyncio.Task] = [] - self._running = False - - def add( - self, - func: AsyncCallback, - rate_hz: Optional[float] = None, - ) -> RateExecutor: - """ - Add a function to execute. - - Args: - func: Async function to execute - rate_hz: Target rate in Hz (None for as-fast-as-possible) - - Returns: - The created RateExecutor - """ - executor = RateExecutor(func=func, rate_hz=rate_hz) - self._executors.append(executor) - return executor - - @property - def running(self) -> bool: - """Check if any executor is running.""" - return self._running - - def start(self) -> None: - """Start all executors.""" - self._running = True - for executor in self._executors: - executor.start() - - def stop(self) -> None: - """Stop all executors.""" - self._running = False - for executor in self._executors: - executor.stop() - - async def run(self) -> None: - """Run all executors concurrently.""" - self.start() - - try: - self._tasks = [ - asyncio.create_task(executor.run()) for executor in self._executors - ] - - await asyncio.gather(*self._tasks, return_exceptions=True) - except asyncio.CancelledError: - pass - finally: - self.stop() - for task in self._tasks: - if not task.done(): - task.cancel() - await asyncio.gather(*self._tasks, return_exceptions=True) - self._tasks.clear() diff --git a/src/cortex/core/node.py b/src/cortex/core/node.py index 4feb7b2..1412479 100644 --- a/src/cortex/core/node.py +++ b/src/cortex/core/node.py @@ -5,22 +5,17 @@ Uses asyncio for cooperative multitasking - ideal for Python < 3.14. """ -from __future__ import annotations - import asyncio import logging -from collections.abc import Coroutine -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable, Coroutine +from typing import Any import zmq import zmq.asyncio -from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS -from cortex.messages.base import Message - -if TYPE_CHECKING: - from cortex.core.publisher import Publisher - from cortex.core.subscriber import Subscriber +from cortex.core import AsyncExecutor, Publisher, RateExecutor, Subscriber +from cortex.discovery import DEFAULT_DISCOVERY_ADDRESS +from cortex.messages import Message logger = logging.getLogger("cortex.node") @@ -35,8 +30,7 @@ class Node: A node in the Cortex communication graph. Nodes manage a collection of publishers and subscribers using asyncio - for cooperative multitasking. This design is faithful to Python < 3.14's - single-threaded nature for CPU-bound work. + for cooperative multitasking. Example: class CameraNode(Node): @@ -111,8 +105,6 @@ def create_publisher( Returns: Publisher instance """ - from cortex.core.publisher import Publisher - if topic_name in self._publishers: logger.warning(f"Publisher for {topic_name} already exists") return self._publishers[topic_name] @@ -154,8 +146,6 @@ def create_subscriber( Returns: Subscriber instance """ - from cortex.core.subscriber import Subscriber - if topic_name in self._subscribers: logger.warning(f"Subscriber for {topic_name} already exists") return self._subscribers[topic_name] @@ -193,8 +183,6 @@ def create_timer( period: Timer period in seconds callback: Async function to call on each timer tick """ - from cortex.core.executor import RateExecutor - rate_hz = 1.0 / period executor = RateExecutor(callback, rate_hz=rate_hz) self._timers.append((period, callback, executor)) @@ -208,9 +196,7 @@ def create_async_executor(self, callback: AsyncCallback) -> None: Args: callback: Async function to execute continuously """ - from cortex.core.executor import RateExecutor - - executor = RateExecutor(callback, rate_hz=None) + executor = AsyncExecutor(callback) self._async_executors.append(executor) logger.debug("Created async executor") @@ -319,7 +305,7 @@ def is_running(self) -> bool: """Check if the node is running.""" return self._running - async def __aenter__(self) -> Node: + async def __aenter__(self) -> "Node": return self async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index f38f9fc..647aeb4 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -5,8 +5,6 @@ and publishes messages on IPC sockets using asyncio. """ -from __future__ import annotations - import contextlib import hashlib import logging @@ -236,7 +234,7 @@ def close(self) -> None: with contextlib.suppress(Exception): os.remove(path) - def __enter__(self) -> Publisher: + def __enter__(self) -> "Publisher": return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index a36ff4a..4186ea8 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -5,14 +5,12 @@ and subscribes to topics using IPC sockets with asyncio. """ -from __future__ import annotations - import asyncio import contextlib import logging import time -from collections.abc import Coroutine -from typing import Any, Callable +from collections.abc import Callable, Coroutine +from typing import Any import zmq import zmq.asyncio @@ -297,7 +295,7 @@ def close(self) -> None: self._connected = False - def __enter__(self) -> Subscriber: + def __enter__(self) -> "Subscriber": return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index 99f984b..59673b6 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -5,8 +5,6 @@ Uses synchronous ZMQ since discovery is typically done once at startup. """ -from __future__ import annotations - import contextlib import logging import threading @@ -278,7 +276,7 @@ def close(self) -> None: self._context.term() self._context = None - def __enter__(self) -> DiscoveryClient: + def __enter__(self) -> "DiscoveryClient": return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index 0c2fd81..c9e4c66 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -15,7 +15,6 @@ import sys import threading import time -from typing import Optional import zmq @@ -68,8 +67,8 @@ def __init__( self._heartbeats: dict[str, float] = {} # ZMQ context and socket - self._context: Optional[zmq.Context] = None - self._socket: Optional[zmq.Socket] = None + self._context: zmq.Context | None = None + self._socket: zmq.Socket | None = None # Control flags self._running = False diff --git a/src/cortex/discovery/protocol.py b/src/cortex/discovery/protocol.py index 5040394..53e48fb 100644 --- a/src/cortex/discovery/protocol.py +++ b/src/cortex/discovery/protocol.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from enum import IntEnum -from typing import Optional import msgpack @@ -64,8 +63,8 @@ class DiscoveryRequest: """Request message for the discovery service.""" command: DiscoveryCommand - topic_info: Optional[TopicInfo] = None # For REGISTER/UNREGISTER - topic_name: Optional[str] = None # For LOOKUP + topic_info: TopicInfo | None = None # For REGISTER/UNREGISTER + topic_name: str | None = None # For LOOKUP def to_bytes(self) -> bytes: """Serialize request to bytes.""" @@ -97,8 +96,8 @@ class DiscoveryResponse: status: DiscoveryStatus message: str = "" - topic_info: Optional[TopicInfo] = None # For LOOKUP - topics: Optional[list[TopicInfo]] = None # For LIST_TOPICS + topic_info: TopicInfo | None = None # For LOOKUP + topics: list[TopicInfo] | None = None # For LIST_TOPICS def to_bytes(self) -> bytes: """Serialize response to bytes.""" diff --git a/src/cortex/messages/standard.py b/src/cortex/messages/standard.py index 81bfe5c..1276b06 100644 --- a/src/cortex/messages/standard.py +++ b/src/cortex/messages/standard.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass -from typing import Any, Optional +from typing import Any import numpy as np @@ -159,9 +159,9 @@ class PointCloudMessage(Message): """ points: np.ndarray # Nx3 array of XYZ coordinates - colors: Optional[np.ndarray] = None # Nx3 array of RGB colors (0-255) - intensity: Optional[np.ndarray] = None # Nx1 array of intensity values - normals: Optional[np.ndarray] = None # Nx3 array of normal vectors + colors: np.ndarray | None = None # Nx3 array of RGB colors (0-255) + intensity: np.ndarray | None = None # Nx1 array of intensity values + normals: np.ndarray | None = None # Nx3 array of normal vectors frame_id: str = "" diff --git a/tests/test_node.py b/tests/test_node.py index f31c63f..765c803 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -2,8 +2,6 @@ Tests for Node class. """ -from __future__ import annotations - import asyncio import contextlib import time diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 0e7faa9..13b7bea 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -2,8 +2,6 @@ Tests for publisher and subscriber. """ -from __future__ import annotations - import asyncio import contextlib import threading From a684f9f831f9346d4bcc47f462661be7cddf9c8d Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 15:48:27 -0500 Subject: [PATCH 04/28] fix circular imports --- src/cortex/core/node.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cortex/core/node.py b/src/cortex/core/node.py index 1412479..f8bb25d 100644 --- a/src/cortex/core/node.py +++ b/src/cortex/core/node.py @@ -13,9 +13,11 @@ import zmq import zmq.asyncio -from cortex.core import AsyncExecutor, Publisher, RateExecutor, Subscriber -from cortex.discovery import DEFAULT_DISCOVERY_ADDRESS -from cortex.messages import Message +from cortex.core.executor import AsyncExecutor, RateExecutor +from cortex.core.publisher import Publisher +from cortex.core.subscriber import Subscriber +from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS +from cortex.messages.base import Message logger = logging.getLogger("cortex.node") From c058d7bc5f70cd94c9fcbc4c7b838602ad48abe5 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 15:54:51 -0500 Subject: [PATCH 05/28] fix graceful termination of daemons for py3.10 --- tests/conftest.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 81b7946..b1ce8df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,10 +77,26 @@ def run_daemon(): def stop(self) -> None: """Stop the discovery daemon.""" if self._daemon: - with contextlib.suppress(Exception): - self._daemon.stop() + # Signal shutdown first + self._daemon._running = False + self._daemon._shutdown_event.set() + + # Close socket with linger=0 to avoid blocking + if self._daemon._socket: + with contextlib.suppress(Exception): + self._daemon._socket.setsockopt(zmq.LINGER, 0) + self._daemon._socket.close() + self._daemon._socket = None + + # Terminate context + if self._daemon._context: + with contextlib.suppress(Exception): + self._daemon._context.term() + self._daemon._context = None + if self._thread: self._thread.join(timeout=1.0) + self._daemon = None self._thread = None From 636f07ae8617c8e86e46b9a40982ffb3f4ce7364 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 17:07:21 -0500 Subject: [PATCH 06/28] added test for executor, made node running simpler --- src/cortex/core/__init__.py | 4 +- src/cortex/core/executor.py | 8 +- src/cortex/core/node.py | 54 ++---- src/cortex/core/publisher.py | 56 +++--- src/cortex/core/subscriber.py | 89 ++++----- src/cortex/core/types.py | 17 ++ tests/test_executor.py | 348 ++++++++++++++++++++++++++++++++++ 7 files changed, 443 insertions(+), 133 deletions(-) create mode 100644 src/cortex/core/types.py create mode 100644 tests/test_executor.py diff --git a/src/cortex/core/__init__.py b/src/cortex/core/__init__.py index 53d877a..3661048 100644 --- a/src/cortex/core/__init__.py +++ b/src/cortex/core/__init__.py @@ -7,12 +7,14 @@ ) from cortex.core.node import Node from cortex.core.publisher import Publisher -from cortex.core.subscriber import MessageCallback, Subscriber +from cortex.core.subscriber import Subscriber +from cortex.core.types import AsyncCallback, MessageCallback __all__ = [ "Node", "Publisher", "Subscriber", + "AsyncCallback", "MessageCallback", "BaseExecutor", "AsyncExecutor", diff --git a/src/cortex/core/executor.py b/src/cortex/core/executor.py index d4d5f8d..135ce2c 100644 --- a/src/cortex/core/executor.py +++ b/src/cortex/core/executor.py @@ -9,14 +9,10 @@ import logging import time from abc import ABC, abstractmethod -from collections.abc import Callable, Coroutine -from typing import Any - -logger = logging.getLogger("cortex.executor") +from cortex.core.types import AsyncCallback -# Type alias for async callbacks -AsyncCallback = Callable[..., Coroutine[Any, Any, None]] +logger = logging.getLogger("cortex.executor") class BaseExecutor(ABC): diff --git a/src/cortex/core/node.py b/src/cortex/core/node.py index f8bb25d..b385122 100644 --- a/src/cortex/core/node.py +++ b/src/cortex/core/node.py @@ -7,26 +7,20 @@ import asyncio import logging -from collections.abc import Callable, Coroutine -from typing import Any import zmq import zmq.asyncio -from cortex.core.executor import AsyncExecutor, RateExecutor +from cortex.core.executor import RateExecutor from cortex.core.publisher import Publisher from cortex.core.subscriber import Subscriber +from cortex.core.types import AsyncCallback, MessageCallback from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS from cortex.messages.base import Message logger = logging.getLogger("cortex.node") -# Type aliases -AsyncCallback = Callable[..., Coroutine[Any, Any, None]] -MessageCallback = Callable[[Message, Any], Coroutine[Any, Any, None]] - - class Node: """ A node in the Cortex communication graph. @@ -77,10 +71,10 @@ def __init__( self._subscribers: dict[str, Subscriber] = {} # Timer executors: (period, callback, RateExecutor) - self._timers: list[tuple[float, AsyncCallback, Any]] = [] + self._timers: list[tuple[float, AsyncCallback, RateExecutor]] = [] - # Async executors (non-rate-limited) - self._async_executors: list[Any] = [] + # Subscribers with callbacks (need to run receive loops) + self._active_subscribers: list[Subscriber] = [] # Tasks self._tasks: list[asyncio.Task] = [] @@ -167,9 +161,9 @@ def create_subscriber( self._subscribers[topic_name] = sub logger.info(f"Created subscriber for {topic_name}") - # Add subscriber's receive loop as an async executor + # Add subscriber to active list if it has a callback if callback is not None: - self._async_executors.append(sub) + self._active_subscribers.append(sub) return sub @@ -191,18 +185,6 @@ def create_timer( logger.debug(f"Created timer with period {period}s ({rate_hz} Hz)") - def create_async_executor(self, callback: AsyncCallback) -> None: - """ - Create an async executor that runs as fast as possible. - - Args: - callback: Async function to execute continuously - """ - executor = AsyncExecutor(callback) - self._async_executors.append(executor) - - logger.debug("Created async executor") - async def run(self) -> None: """ Run the node, processing messages and timers. @@ -213,15 +195,11 @@ async def run(self) -> None: # Start all timer executors for _period, _callback, executor in self._timers: - executor.start() self._tasks.append(asyncio.create_task(executor.run())) - # Start all async executors (including subscriber receive loops) - for executor in self._async_executors: - if hasattr(executor, "start"): - executor.start() - if hasattr(executor, "run"): - self._tasks.append(asyncio.create_task(executor.run())) + # Start all subscriber receive loops + for sub in self._active_subscribers: + self._tasks.append(asyncio.create_task(sub.run())) logger.info(f"Node {self.name} running with {len(self._tasks)} tasks") @@ -234,9 +212,8 @@ async def run(self) -> None: # Stop all executors for _period, _callback, executor in self._timers: executor.stop() - for executor in self._async_executors: - if hasattr(executor, "stop"): - executor.stop() + for sub in self._active_subscribers: + sub.stop() def stop(self) -> None: """Stop the node.""" @@ -246,9 +223,8 @@ def stop(self) -> None: # Stop all executors for _period, _callback, executor in self._timers: executor.stop() - for executor in self._async_executors: - if hasattr(executor, "stop"): - executor.stop() + for sub in self._active_subscribers: + sub.stop() # Cancel all tasks for task in self._tasks: @@ -277,7 +253,7 @@ async def close(self) -> None: self._subscribers.clear() self._timers.clear() - self._async_executors.clear() + self._active_subscribers.clear() # Terminate ZMQ context self._context.term() diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index 647aeb4..995a009 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -3,6 +3,8 @@ Provides a ZeroMQ-based publisher that registers with the discovery daemon and publishes messages on IPC sockets using asyncio. + +Note: Publishers are always created through Node.create_publisher(). """ import contextlib @@ -47,13 +49,13 @@ class Publisher: Uses ZeroMQ PUB socket over IPC for efficient local communication. Automatically registers with the discovery daemon. + Note: Always create publishers through Node.create_publisher(). + Example: - pub = Publisher( - topic_name="/camera/image", - message_type=ImageMessage, - node_name="camera_node" - ) - pub.publish(ImageMessage(data=image_array)) + async with Node("camera_node") as node: + pub = node.create_publisher("/camera/image", ImageMessage) + pub.publish(ImageMessage(data=image_array)) + await node.run() """ def __init__( @@ -76,7 +78,7 @@ def __init__( discovery_address: Address of the discovery daemon queue_size: High-water mark for outgoing messages auto_register: Whether to automatically register with discovery daemon - context: Optional shared ZMQ async context + context: Shared ZMQ async context from Node """ self.topic_name = topic_name self.message_type = message_type @@ -87,9 +89,8 @@ def __init__( # Generate IPC address for this topic self.address = generate_ipc_address(topic_name) - # ZMQ setup - use provided context or create new one + # ZMQ setup - context provided by Node self._context: zmq.asyncio.Context = context or zmq.asyncio.Context() - self._owns_context = context is None self._socket: zmq.asyncio.Socket | None = None # Discovery client @@ -107,14 +108,12 @@ def __init__( def _setup_socket(self) -> None: """Set up the ZMQ publisher socket.""" - # Ensure the IPC directory exists - if self.address.startswith("ipc://"): - path = self.address[6:] - dir_path = os.path.dirname(path) - os.makedirs(dir_path, exist_ok=True) - # Remove stale socket file - if os.path.exists(path): - os.remove(path) + # Ensure the IPC directory exists and remove stale socket file + path = self.address[6:] # Remove "ipc://" prefix + dir_path = os.path.dirname(path) + os.makedirs(dir_path, exist_ok=True) + if os.path.exists(path): + os.remove(path) self._socket = self._context.socket(zmq.PUB) @@ -222,20 +221,11 @@ def close(self) -> None: self._socket.close() self._socket = None - # Only terminate context if we own it - if self._owns_context and self._context: - self._context.term() - self._context = None - # Clean up IPC socket file - if self.address.startswith("ipc://"): - path = self.address[6:] - if os.path.exists(path): - with contextlib.suppress(Exception): - os.remove(path) - - def __enter__(self) -> "Publisher": - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - self.close() + assert self.address.startswith("ipc://"), ( + "CRITICAL: ADDRESS ALWAYS STARTS WITH ipc:// -- UNLESS MANUALLY CHANGED" + ) + path = self.address[6:] # Remove "ipc://" prefix + if os.path.exists(path): + with contextlib.suppress(Exception): + os.remove(path) diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index 4186ea8..4a6b765 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -3,18 +3,20 @@ Provides a ZeroMQ-based subscriber that queries the discovery daemon and subscribes to topics using IPC sockets with asyncio. + +Note: Subscribers are always created through Node.create_subscriber(). """ import asyncio import contextlib import logging import time -from collections.abc import Callable, Coroutine -from typing import Any import zmq import zmq.asyncio +from cortex.core.executor import AsyncExecutor +from cortex.core.types import MessageCallback from cortex.discovery.client import DiscoveryClient from cortex.discovery.daemon import DEFAULT_DISCOVERY_ADDRESS from cortex.discovery.protocol import TopicInfo @@ -23,10 +25,6 @@ logger = logging.getLogger("cortex.subscriber") -# Type for async message callback -MessageCallback = Callable[[Message, MessageHeader], Coroutine[Any, Any, None]] - - class Subscriber: """ Subscriber for receiving messages on a topic. @@ -34,17 +32,12 @@ class Subscriber: Uses ZeroMQ SUB socket over IPC for efficient local communication. Automatically discovers the topic using the discovery daemon. + Note: Always create subscribers through Node.create_subscriber(). + Example: async def callback(msg, header): - print(f"Received image at {header.timestamp_ns}") + print(f"Received: {msg}") - sub = Subscriber( - topic_name="/camera/image", - message_type=ImageMessage, - callback=callback - ) - - # Run as part of node async with Node("my_node") as node: node.create_subscriber("/topic", MyMsg, callback) await node.run() @@ -76,7 +69,7 @@ def __init__( auto_connect: Whether to automatically connect on creation wait_for_topic: Whether to wait for topic to be available topic_timeout: Timeout for waiting for topic (seconds) - context: Optional shared ZMQ async context + context: Shared ZMQ async context from Node """ self.topic_name = topic_name self.message_type = message_type @@ -90,9 +83,8 @@ def __init__( self._topic_info: TopicInfo | None = None self._connected = False - # ZMQ setup - use provided context or create new one + # ZMQ setup - context provided by Node self._context: zmq.asyncio.Context = context or zmq.asyncio.Context() - self._owns_context = context is None self._socket: zmq.asyncio.Socket | None = None # Discovery client @@ -102,8 +94,8 @@ def __init__( self._receive_count = 0 self._last_receive_time: float | None = None - # State - self._running = False + # Executor for receive loop + self._executor: AsyncExecutor | None = None # Initialize if auto_connect: @@ -207,45 +199,43 @@ async def receive(self) -> tuple[Message, MessageHeader] | None: logger.error(f"Failed to receive message: {e}") return None + async def _receive_and_callback(self) -> None: + """Receive a message and invoke the callback.""" + result = await self.receive() + if result: + message, header = result + await self._callback(message, header) + def start(self) -> None: """Start the subscriber receive loop.""" - self._running = True + if self._executor: + self._executor.start() def stop(self) -> None: """Stop the subscriber receive loop.""" - self._running = False + if self._executor: + self._executor.stop() + + @property + def running(self) -> bool: + """Check if the subscriber is running.""" + return self._executor.running if self._executor else False async def run(self) -> None: """ Run the subscriber's async receive loop. Continuously receives messages and calls the callback. + Uses AsyncExecutor for consistent execution pattern. """ if self._callback is None: logger.warning(f"No callback set for subscriber {self.topic_name}") return - self._running = True logger.info(f"Subscriber for {self.topic_name} running") - while self._running: - try: - result = await self.receive() - - if result: - message, header = result - try: - await self._callback(message, header) - except Exception as e: - logger.error(f"Error in callback: {e}") - - await asyncio.sleep(0) # Yield to event loop - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"Error in receive loop: {e}") - await asyncio.sleep(0.001) + self._executor = AsyncExecutor(self._receive_and_callback) + await self._executor.run() logger.info(f"Subscriber for {self.topic_name} stopped") @@ -270,10 +260,13 @@ def last_receive_time(self) -> float | None: return self._last_receive_time def close(self) -> None: - """Close the subscriber.""" + """Close the subscriber and release resources.""" logger.info(f"Closing subscriber for {self.topic_name}") - self._running = False + # Stop the executor + if self._executor: + self._executor.stop() + self._executor = None # Close discovery client (best effort - daemon may be gone) if self._discovery_client: @@ -287,16 +280,4 @@ def close(self) -> None: self._socket.close() self._socket = None - # Only terminate context if we own it - if self._owns_context and self._context: - with contextlib.suppress(Exception): - self._context.term() - self._context = None - self._connected = False - - def __enter__(self) -> "Subscriber": - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - self.close() diff --git a/src/cortex/core/types.py b/src/cortex/core/types.py new file mode 100644 index 0000000..d7170af --- /dev/null +++ b/src/cortex/core/types.py @@ -0,0 +1,17 @@ +""" +Type aliases for Cortex core module. + +This module contains all type aliases used across the core module +to ensure consistency and avoid circular imports. +""" + +from collections.abc import Callable, Coroutine +from typing import Any + +from cortex.messages.base import Message, MessageHeader + +# Async callback with any arguments +AsyncCallback = Callable[..., Coroutine[Any, Any, None]] + +# Callback for message reception: (message, header) -> None +MessageCallback = Callable[[Message, MessageHeader], Coroutine[Any, Any, None]] diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..57eda8c --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,348 @@ +""" +Tests for executor classes. +""" + +import asyncio +import contextlib +import time + +import pytest + +from cortex.core.executor import AsyncExecutor, BaseExecutor, RateExecutor + + +class TestBaseExecutor: + """Tests for BaseExecutor base class.""" + + def test_cannot_instantiate_base_executor(self): + """BaseExecutor is abstract and cannot be instantiated.""" + + async def dummy(): + pass + + with pytest.raises(TypeError): + BaseExecutor(dummy) + + def test_start_sets_running(self): + """Start should set running to True.""" + + async def dummy(): + pass + + executor = AsyncExecutor(dummy) + assert not executor.running + + executor.start() + assert executor.running + + def test_stop_clears_running(self): + """Stop should set running to False.""" + + async def dummy(): + pass + + executor = AsyncExecutor(dummy) + executor.start() + assert executor.running + + executor.stop() + assert not executor.running + + +class TestAsyncExecutor: + """Tests for AsyncExecutor class.""" + + @pytest.mark.asyncio + async def test_executes_callback(self): + """AsyncExecutor should execute the callback.""" + call_count = 0 + + async def callback(): + nonlocal call_count + call_count += 1 + + executor = AsyncExecutor(callback) + + # Run for a short time + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.05) + executor.stop() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert call_count > 0 + + @pytest.mark.asyncio + async def test_runs_as_fast_as_possible(self): + """AsyncExecutor should run many times quickly.""" + call_count = 0 + + async def callback(): + nonlocal call_count + call_count += 1 + + executor = AsyncExecutor(callback) + + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.1) + executor.stop() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + # Should execute many times in 100ms + assert call_count > 100 + + @pytest.mark.asyncio + async def test_run_calls_start_and_stop(self): + """Run should call start() and stop() automatically.""" + states: list[bool] = [] + + async def callback(): + # Capture running state during execution + pass + + executor = AsyncExecutor(callback) + + # Before run + assert not executor.running + + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.01) + + # During run + states.append(executor.running) + + executor.stop() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + # After run completes, should still be stopped + assert not executor.running + assert states[0] is True # Was running during execution + + @pytest.mark.asyncio + async def test_handles_callback_exception(self): + """AsyncExecutor should handle exceptions in callback.""" + call_count = 0 + + async def failing_callback(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ValueError("Test error") + + executor = AsyncExecutor(failing_callback) + + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.05) + executor.stop() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + # Should continue despite exceptions + assert call_count >= 3 + + @pytest.mark.asyncio + async def test_stops_on_cancellation(self): + """AsyncExecutor should stop cleanly on task cancellation.""" + call_count = 0 + + async def callback(): + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.01) + + executor = AsyncExecutor(callback) + run_task = asyncio.create_task(executor.run()) + + await asyncio.sleep(0.05) + run_task.cancel() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert not executor.running + + +class TestRateExecutor: + """Tests for RateExecutor class.""" + + @pytest.mark.asyncio + async def test_executes_at_target_rate(self): + """RateExecutor should execute approximately at target rate.""" + call_times: list[float] = [] + + async def callback(): + call_times.append(time.perf_counter()) + + rate_hz = 20.0 # 20 Hz = 50ms interval + executor = RateExecutor(callback, rate_hz=rate_hz) + + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.5) # Run for 500ms + executor.stop() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + # Should have approximately 10 calls in 500ms at 20Hz + assert 8 <= len(call_times) <= 15 + + @pytest.mark.asyncio + async def test_interval_property(self): + """RateExecutor should have correct interval.""" + + async def callback(): + pass + + executor = RateExecutor(callback, rate_hz=10.0) + assert executor.interval == 0.1 # 100ms + + executor2 = RateExecutor(callback, rate_hz=100.0) + assert executor2.interval == 0.01 # 10ms + + @pytest.mark.asyncio + async def test_run_calls_start_and_stop(self): + """Run should call start() and stop() automatically.""" + + async def callback(): + pass + + executor = RateExecutor(callback, rate_hz=10.0) + + assert not executor.running + + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.05) + + assert executor.running + + executor.stop() + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert not executor.running + + @pytest.mark.asyncio + async def test_handles_callback_exception(self): + """RateExecutor should handle exceptions in callback.""" + call_count = 0 + + async def failing_callback(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ValueError("Test error") + + executor = RateExecutor(failing_callback, rate_hz=100.0) + + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.1) + executor.stop() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + # Should continue despite exceptions + assert call_count >= 3 + + @pytest.mark.asyncio + async def test_maintains_timing_with_slow_callback(self): + """RateExecutor should maintain timing even with slow callbacks.""" + call_times: list[float] = [] + + async def slow_callback(): + call_times.append(time.perf_counter()) + await asyncio.sleep(0.02) # 20ms work + + # 10 Hz = 100ms interval, callback takes 20ms + executor = RateExecutor(slow_callback, rate_hz=10.0) + + run_task = asyncio.create_task(executor.run()) + await asyncio.sleep(0.5) + executor.stop() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + # Should still execute approximately on schedule + assert 4 <= len(call_times) <= 7 + + @pytest.mark.asyncio + async def test_stops_on_cancellation(self): + """RateExecutor should stop cleanly on task cancellation.""" + call_count = 0 + + async def callback(): + nonlocal call_count + call_count += 1 + + executor = RateExecutor(callback, rate_hz=10.0) + run_task = asyncio.create_task(executor.run()) + + await asyncio.sleep(0.15) + run_task.cancel() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert not executor.running + + +class TestExecutorIntegration: + """Integration tests for executors.""" + + @pytest.mark.asyncio + async def test_multiple_executors_concurrent(self): + """Multiple executors should run concurrently.""" + async_count = 0 + rate_count = 0 + + async def async_callback(): + nonlocal async_count + async_count += 1 + + async def rate_callback(): + nonlocal rate_count + rate_count += 1 + + async_exec = AsyncExecutor(async_callback) + rate_exec = RateExecutor(rate_callback, rate_hz=50.0) + + task1 = asyncio.create_task(async_exec.run()) + task2 = asyncio.create_task(rate_exec.run()) + + await asyncio.sleep(0.2) + + async_exec.stop() + rate_exec.stop() + + with contextlib.suppress(asyncio.CancelledError): + await asyncio.gather(task1, task2, return_exceptions=True) + + # Both should have executed + assert async_count > 100 # AsyncExecutor runs fast + assert 8 <= rate_count <= 15 # ~10 at 50Hz in 200ms + + @pytest.mark.asyncio + async def test_executor_with_args(self): + """Executors should pass args to callback.""" + received_args: list[tuple] = [] + + async def callback(*args, **kwargs): + received_args.append((args, kwargs)) + + executor = AsyncExecutor(callback) + + run_task = asyncio.create_task(executor.run(1, 2, key="value")) + await asyncio.sleep(0.02) + executor.stop() + + with contextlib.suppress(asyncio.CancelledError): + await run_task + + assert len(received_args) > 0 + assert received_args[0] == ((1, 2), {"key": "value"}) From e2198709750920af26df01e1206937c3a32678e4 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 17:13:46 -0500 Subject: [PATCH 07/28] dont terminate context from daemon thread --- tests/conftest.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b1ce8df..b2a0ea8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -88,17 +88,19 @@ def stop(self) -> None: self._daemon._socket.close() self._daemon._socket = None - # Terminate context - if self._daemon._context: - with contextlib.suppress(Exception): - self._daemon._context.term() - self._daemon._context = None - + # Wait for thread to finish if self._thread: - self._thread.join(timeout=1.0) + self._thread.join(timeout=2.0) + self._thread = None + + # Don't explicitly terminate the ZMQ context - it causes crashes when + # called from a different thread than where it was created. + # Let Python's garbage collector handle context cleanup. + # The socket is already closed with linger=0, so no messages will block. + if self._daemon: + self._daemon._context = None self._daemon = None - self._thread = None @pytest.fixture From 0bc3f8c89ff1cd54984b6c41b1911be71f5de226 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 17:28:07 -0500 Subject: [PATCH 08/28] prevent race conditions in discovery client --- src/cortex/discovery/client.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index 59673b6..7065d92 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -69,10 +69,14 @@ def _ensure_connected(self) -> None: self._socket.setsockopt(zmq.LINGER, 0) self._socket.connect(self.discovery_address) - def _reconnect(self) -> None: - """Reconnect to the discovery daemon.""" - self.close() - self._ensure_connected() + def _reset_connection(self) -> None: + """Reset the connection by closing socket. Next request will reconnect.""" + if self._socket: + with contextlib.suppress(Exception): + self._socket.setsockopt(zmq.LINGER, 0) + self._socket.close() + self._socket = None + self._context = None def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: """Send a request and wait for response with retries.""" @@ -90,11 +94,11 @@ def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: f"Discovery request timed out after {self.timeout_ms}ms" ) logger.warning(f"Request timeout, attempt {attempt + 1}/{self.retries}") - self._reconnect() + self._reset_connection() except zmq.ZMQError as e: last_error = e logger.warning(f"ZMQ error: {e}, attempt {attempt + 1}/{self.retries}") - self._reconnect() + self._reset_connection() raise last_error @@ -245,6 +249,8 @@ def _heartbeat_loop(self) -> None: """Background thread that sends heartbeats for registered topics.""" while self._heartbeat_running and self._heartbeat_topics: for topic_name in list(self._heartbeat_topics.keys()): + if not self._heartbeat_running: + break if topic_name in self._heartbeat_topics: self._send_heartbeat(topic_name) @@ -268,13 +274,12 @@ def close(self) -> None: if self._socket: with contextlib.suppress(Exception): + self._socket.setsockopt(zmq.LINGER, 0) self._socket.close() self._socket = None - if self._context: - with contextlib.suppress(Exception): - self._context.term() - self._context = None + # Don't call context.term() - it can crash in cross-thread scenarios + self._context = None def __enter__(self) -> "DiscoveryClient": return self From 4a8df9e8df0931a70d25d1e23b292f43c82a6939 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 17:38:08 -0500 Subject: [PATCH 09/28] linger update --- pyproject.toml | 6 +++--- src/cortex/core/publisher.py | 2 ++ src/cortex/core/subscriber.py | 2 ++ src/cortex/discovery/client.py | 11 ++++++----- src/cortex/discovery/daemon.py | 4 +++- tests/conftest.py | 12 +++++------- 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b0ae76..356cc47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,14 +22,14 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [ - "pyzmq>=25.0.0", - "numpy>=1.20.0", + "pyzmq>=27.0.0", + "numpy>=1.26.4", "msgpack>=1.0.0", ] [project.optional-dependencies] torch = [ - "torch>=2.0.0", + "torch>=2.6.0", ] dev = [ "pytest>=7.0.0", diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index 995a009..e8621a0 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -119,6 +119,8 @@ def _setup_socket(self) -> None: # Set high-water mark (queue size) self._socket.setsockopt(zmq.SNDHWM, self.queue_size) + # Set linger for graceful shutdown + self._socket.setsockopt(zmq.LINGER, 1000) # Bind to the address self._socket.bind(self.address) diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index 4a6b765..583849b 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -155,6 +155,8 @@ def _setup_socket(self, address: str) -> None: # Set high-water mark self._socket.setsockopt(zmq.RCVHWM, self.queue_size) + # Set linger for graceful shutdown + self._socket.setsockopt(zmq.LINGER, 1000) # Subscribe to topic self._socket.setsockopt_string(zmq.SUBSCRIBE, self.topic_name) diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index 7065d92..b20f511 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -66,14 +66,14 @@ def _ensure_connected(self) -> None: self._socket = self._context.socket(zmq.REQ) self._socket.setsockopt(zmq.RCVTIMEO, self.timeout_ms) self._socket.setsockopt(zmq.SNDTIMEO, self.timeout_ms) - self._socket.setsockopt(zmq.LINGER, 0) + # Set LINGER to half of timeout to allow graceful shutdown + self._socket.setsockopt(zmq.LINGER, self.timeout_ms // 2) self._socket.connect(self.discovery_address) def _reset_connection(self) -> None: """Reset the connection by closing socket. Next request will reconnect.""" if self._socket: with contextlib.suppress(Exception): - self._socket.setsockopt(zmq.LINGER, 0) self._socket.close() self._socket = None self._context = None @@ -274,12 +274,13 @@ def close(self) -> None: if self._socket: with contextlib.suppress(Exception): - self._socket.setsockopt(zmq.LINGER, 0) self._socket.close() self._socket = None - # Don't call context.term() - it can crash in cross-thread scenarios - self._context = None + if self._context: + with contextlib.suppress(Exception): + self._context.term() + self._context = None def __enter__(self) -> "DiscoveryClient": return self diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index c9e4c66..c862cc7 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -97,6 +97,9 @@ def start(self) -> None: # Set socket options for responsiveness self._socket.setsockopt(zmq.RCVTIMEO, 1000) # 1 second timeout + self._socket.setsockopt( + zmq.LINGER, 500 + ) # Half of RCVTIMEO for graceful shutdown self._running = True self._shutdown_event.clear() @@ -307,7 +310,6 @@ def stop(self) -> None: if self._socket: try: - self._socket.setsockopt(zmq.LINGER, 0) self._socket.close() except Exception as e: logger.debug(f"Error closing socket: {e}") diff --git a/tests/conftest.py b/tests/conftest.py index b2a0ea8..104a1a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -81,10 +81,9 @@ def stop(self) -> None: self._daemon._running = False self._daemon._shutdown_event.set() - # Close socket with linger=0 to avoid blocking + # Close socket (LINGER is already set on creation) if self._daemon._socket: with contextlib.suppress(Exception): - self._daemon._socket.setsockopt(zmq.LINGER, 0) self._daemon._socket.close() self._daemon._socket = None @@ -93,11 +92,10 @@ def stop(self) -> None: self._thread.join(timeout=2.0) self._thread = None - # Don't explicitly terminate the ZMQ context - it causes crashes when - # called from a different thread than where it was created. - # Let Python's garbage collector handle context cleanup. - # The socket is already closed with linger=0, so no messages will block. - if self._daemon: + # Terminate context + if self._daemon and self._daemon._context: + with contextlib.suppress(Exception): + self._daemon._context.term() self._daemon._context = None self._daemon = None From a19aaf011e07446f1e024ac25329849817efb38f Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Fri, 28 Nov 2025 17:46:22 -0500 Subject: [PATCH 10/28] stop discovery client from killing context --- src/cortex/core/publisher.py | 5 +++-- src/cortex/core/subscriber.py | 5 +++-- src/cortex/discovery/client.py | 3 +-- src/cortex/discovery/daemon.py | 4 +--- tests/conftest.py | 19 ++++--------------- 5 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index e8621a0..26186ab 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -119,8 +119,9 @@ def _setup_socket(self) -> None: # Set high-water mark (queue size) self._socket.setsockopt(zmq.SNDHWM, self.queue_size) - # Set linger for graceful shutdown - self._socket.setsockopt(zmq.LINGER, 1000) + + # Set linger to 0 for immediate shutdown (close all sockets before context.term) + self._socket.setsockopt(zmq.LINGER, 0) # Bind to the address self._socket.bind(self.address) diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index 583849b..482f301 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -155,8 +155,9 @@ def _setup_socket(self, address: str) -> None: # Set high-water mark self._socket.setsockopt(zmq.RCVHWM, self.queue_size) - # Set linger for graceful shutdown - self._socket.setsockopt(zmq.LINGER, 1000) + + # Set linger to 0 for immediate shutdown + self._socket.setsockopt(zmq.LINGER, 0) # Subscribe to topic self._socket.setsockopt_string(zmq.SUBSCRIBE, self.topic_name) diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index b20f511..fb10fce 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -66,8 +66,7 @@ def _ensure_connected(self) -> None: self._socket = self._context.socket(zmq.REQ) self._socket.setsockopt(zmq.RCVTIMEO, self.timeout_ms) self._socket.setsockopt(zmq.SNDTIMEO, self.timeout_ms) - # Set LINGER to half of timeout to allow graceful shutdown - self._socket.setsockopt(zmq.LINGER, self.timeout_ms // 2) + self._socket.setsockopt(zmq.LINGER, 0) # Immediate shutdown self._socket.connect(self.discovery_address) def _reset_connection(self) -> None: diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index c862cc7..e9765ac 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -97,9 +97,7 @@ def start(self) -> None: # Set socket options for responsiveness self._socket.setsockopt(zmq.RCVTIMEO, 1000) # 1 second timeout - self._socket.setsockopt( - zmq.LINGER, 500 - ) # Half of RCVTIMEO for graceful shutdown + self._socket.setsockopt(zmq.LINGER, 0) # Immediate shutdown self._running = True self._shutdown_event.clear() diff --git a/tests/conftest.py b/tests/conftest.py index 104a1a5..c3dd5b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,7 +65,7 @@ def start(self) -> None: def run_daemon(): self._started.set() with contextlib.suppress(Exception): - self._daemon.start() # Daemon was stopped + self._daemon.start() # start() calls stop() in finally block self._thread = threading.Thread(target=run_daemon, daemon=True) self._thread.start() @@ -77,27 +77,16 @@ def run_daemon(): def stop(self) -> None: """Stop the discovery daemon.""" if self._daemon: - # Signal shutdown first + # Signal shutdown - daemon's start() will call stop() which + # closes socket and terminates context in the same thread self._daemon._running = False self._daemon._shutdown_event.set() - # Close socket (LINGER is already set on creation) - if self._daemon._socket: - with contextlib.suppress(Exception): - self._daemon._socket.close() - self._daemon._socket = None - - # Wait for thread to finish + # Wait for thread to finish (daemon cleans up its own context) if self._thread: self._thread.join(timeout=2.0) self._thread = None - # Terminate context - if self._daemon and self._daemon._context: - with contextlib.suppress(Exception): - self._daemon._context.term() - self._daemon._context = None - self._daemon = None From 3d861f2207b0d9d365072a9c963fc30146406712 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 02:03:23 -0500 Subject: [PATCH 11/28] do not busy wait to get topics. move waiting to asyncio when subscriber runs --- benchmarks/bench_throughput.py | 1 + src/cortex/core/publisher.py | 5 +- src/cortex/core/subscriber.py | 109 ++++++++++++++++++++------------- src/cortex/core/types.py | 4 +- src/cortex/discovery/client.py | 34 +++++++++- src/cortex/discovery/daemon.py | 13 +--- tests/test_pubsub.py | 36 ++++++----- 7 files changed, 127 insertions(+), 75 deletions(-) diff --git a/benchmarks/bench_throughput.py b/benchmarks/bench_throughput.py index 836552c..1fb2fde 100644 --- a/benchmarks/bench_throughput.py +++ b/benchmarks/bench_throughput.py @@ -260,6 +260,7 @@ def main(): 262144, # 256 KB 1048576, # 1 MB - large messages 4194304, # 4 MB - very large (like images) + 16777216, # 16 MB - very large (like high-res images) ] duration = 3.0 # seconds per test diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index 26186ab..9c2bbf3 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -3,8 +3,6 @@ Provides a ZeroMQ-based publisher that registers with the discovery daemon and publishes messages on IPC sockets using asyncio. - -Note: Publishers are always created through Node.create_publisher(). """ import contextlib @@ -32,6 +30,7 @@ def generate_ipc_address(topic_name: str) -> str: """ # Create a safe filename from topic name safe_name = topic_name.replace("/", "_").lstrip("_") + # Add hash suffix for uniqueness hash_suffix = hashlib.md5(topic_name.encode()).hexdigest()[:8] @@ -39,7 +38,7 @@ def generate_ipc_address(topic_name: str) -> str: ipc_dir = "/tmp/cortex/topics" os.makedirs(ipc_dir, exist_ok=True) - return f"ipc://{ipc_dir}/{safe_name}_{hash_suffix}" + return f"ipc://{ipc_dir}/{safe_name}_{hash_suffix}.sock" class Publisher: diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index 482f301..9463f1c 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -3,14 +3,13 @@ Provides a ZeroMQ-based subscriber that queries the discovery daemon and subscribes to topics using IPC sockets with asyncio. - -Note: Subscribers are always created through Node.create_subscriber(). """ import asyncio import contextlib import logging import time +from typing import Any import zmq import zmq.asyncio @@ -51,9 +50,8 @@ def __init__( node_name: str = "anonymous", discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, queue_size: int = 10, - auto_connect: bool = True, wait_for_topic: bool = True, - topic_timeout: float = 30.0, + topic_timeout: float = 600.0, context: zmq.asyncio.Context | None = None, ): """ @@ -66,7 +64,6 @@ def __init__( node_name: Name of the node creating this subscriber discovery_address: Address of the discovery daemon queue_size: High-water mark for incoming messages - auto_connect: Whether to automatically connect on creation wait_for_topic: Whether to wait for topic to be available topic_timeout: Timeout for waiting for topic (seconds) context: Shared ZMQ async context from Node @@ -78,6 +75,7 @@ def __init__( self.discovery_address = discovery_address self.queue_size = queue_size self.topic_timeout = topic_timeout + self._wait_for_topic = wait_for_topic # Connection info self._topic_info: TopicInfo | None = None @@ -88,7 +86,9 @@ def __init__( self._socket: zmq.asyncio.Socket | None = None # Discovery client - self._discovery_client: DiscoveryClient | None = None + self._discovery_client: DiscoveryClient | None = DiscoveryClient( + discovery_address=self.discovery_address + ) # Statistics self._receive_count = 0 @@ -97,58 +97,81 @@ def __init__( # Executor for receive loop self._executor: AsyncExecutor | None = None - # Initialize - if auto_connect: - self._connect(wait=wait_for_topic) + # Try non-blocking connect (will succeed if topic already exists) + self._connect() - def _connect(self, wait: bool = True) -> bool: + def _connect(self) -> bool: """ - Connect to the topic. - - Args: - wait: Whether to wait for the topic to be available + Connect to the topic (non-blocking lookup only). Returns: True if connected successfully """ try: - self._discovery_client = DiscoveryClient( - discovery_address=self.discovery_address - ) + # Non-blocking lookup only + self._topic_info = self._discovery_client.lookup_topic(self.topic_name) + return self._finalize_connection() + + except Exception as e: + logger.error(f"Failed to connect to topic: {e}") + return False - # Look up the topic - if wait: + async def _async_connect(self) -> bool: + """ + Async connect to the topic, waiting if necessary. + + Uses DiscoveryClient.wait_for_topic_async for non-blocking wait. + + Returns: + True if connected successfully + """ + if self._connected: + return True + + try: + if self._wait_for_topic: logger.info(f"Waiting for topic {self.topic_name}...") - self._topic_info = self._discovery_client.wait_for_topic( + self._topic_info = await self._discovery_client.wait_for_topic_async( self.topic_name, timeout=self.topic_timeout ) else: self._topic_info = self._discovery_client.lookup_topic(self.topic_name) - if self._topic_info: - # Verify message type - if self._topic_info.fingerprint != self.message_type.fingerprint(): - logger.warning( - f"Message type mismatch for {self.topic_name}: " - f"expected {self.message_type.__name__}, " - f"got {self._topic_info.message_type}" - ) - - # Connect to the publisher - self._setup_socket(self._topic_info.address) - self._connected = True - logger.info( - f"Connected to topic {self.topic_name} at {self._topic_info.address}" - ) - return True - else: - logger.warning(f"Topic {self.topic_name} not found") - return False + return self._finalize_connection() except Exception as e: logger.error(f"Failed to connect to topic: {e}") return False + def _finalize_connection(self) -> bool: + """ + Finalize connection after topic info is obtained. + + Returns: + True if connected successfully + """ + if self._topic_info: + # Verify message type + if self._topic_info.fingerprint != self.message_type.fingerprint(): + logger.warning( + f"Message type mismatch for {self.topic_name}: " + f"expected {self.message_type.__name__}, " + f"got {self._topic_info.message_type}" + ) + + # Connect to the publisher + self._setup_socket(self._topic_info.address) + self._connected = True + logger.info( + f"Connected to topic {self.topic_name} at {self._topic_info.address}" + ) + return True + else: + logger.warning( + f"Topic {self.topic_name} not found yet, will retry in run()" + ) + return False + def _setup_socket(self, address: str) -> None: """Set up the ZMQ subscriber socket.""" self._socket = self._context.socket(zmq.SUB) @@ -202,12 +225,12 @@ async def receive(self) -> tuple[Message, MessageHeader] | None: logger.error(f"Failed to receive message: {e}") return None - async def _receive_and_callback(self) -> None: + async def _receive_and_callback(self) -> Any: """Receive a message and invoke the callback.""" result = await self.receive() if result: message, header = result - await self._callback(message, header) + return await self._callback(message, header) def start(self) -> None: """Start the subscriber receive loop.""" @@ -235,6 +258,10 @@ async def run(self) -> None: logger.warning(f"No callback set for subscriber {self.topic_name}") return + if not self._connected and not await self._async_connect(): + logger.error(f"Failed to connect subscriber for {self.topic_name}") + return + logger.info(f"Subscriber for {self.topic_name} running") self._executor = AsyncExecutor(self._receive_and_callback) diff --git a/src/cortex/core/types.py b/src/cortex/core/types.py index d7170af..6b0ca74 100644 --- a/src/cortex/core/types.py +++ b/src/cortex/core/types.py @@ -13,5 +13,5 @@ # Async callback with any arguments AsyncCallback = Callable[..., Coroutine[Any, Any, None]] -# Callback for message reception: (message, header) -> None -MessageCallback = Callable[[Message, MessageHeader], Coroutine[Any, Any, None]] +# Callback for message reception: (message, header) -> Any +MessageCallback = Callable[[Message, MessageHeader], Coroutine[Any, Any, Any]] diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index fb10fce..e370520 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -5,6 +5,7 @@ Uses synchronous ZMQ since discovery is typically done once at startup. """ +import asyncio import contextlib import logging import threading @@ -36,7 +37,7 @@ def __init__( self, discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, timeout_ms: int = 5000, - retries: int = 3, + retries: int = 1, ): """ Initialize the discovery client. @@ -189,7 +190,7 @@ def wait_for_topic( poll_interval: float = 0.5, ) -> TopicInfo | None: """ - Wait for a topic to become available. + Wait for a topic to become available (blocking). Args: topic_name: Name of the topic to wait for @@ -209,6 +210,35 @@ def wait_for_topic( return None + async def wait_for_topic_async( + self, + topic_name: str, + timeout: float = 600.0, + poll_interval: float = 0.5, + ) -> TopicInfo | None: + """ + Wait for a topic to become available (async, non-blocking). + + Uses asyncio.sleep to avoid blocking the event loop. + + Args: + topic_name: Name of the topic to wait for + timeout: Maximum time to wait in seconds + poll_interval: Time between lookup attempts in seconds + + Returns: + TopicInfo if found within timeout, None otherwise + """ + start_time = time.perf_counter() + + while time.perf_counter() - start_time < timeout: + topic_info = self.lookup_topic(topic_name) + if topic_info: + return topic_info + await asyncio.sleep(poll_interval) + + return None + def list_topics(self) -> list[TopicInfo]: """ List all registered topics. diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index e9765ac..dbdb8d5 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -11,8 +11,6 @@ import contextlib import logging import os -import signal -import sys import threading import time @@ -34,7 +32,7 @@ # Default discovery address -DEFAULT_DISCOVERY_ADDRESS = "ipc:///tmp/cortex_discovery" +DEFAULT_DISCOVERY_ADDRESS = "ipc:///tmp/cortex/discovery.sock" class DiscoveryDaemon: @@ -363,15 +361,6 @@ def main(): address=args.address, cleanup_interval=args.cleanup_interval ) - # Handle signals - def signal_handler(signum, frame): - logger.info(f"Received signal {signum}") - daemon.stop() - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - daemon.start() diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 13b7bea..e97afed 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -4,7 +4,6 @@ import asyncio import contextlib -import threading import time from dataclasses import dataclass @@ -184,12 +183,14 @@ async def callback(msg: SampleMessage, header: MessageHeader) -> None: sub.close() pub.close() - def test_subscriber_waits_for_topic(self, discovery_daemon, discovery_address): + @pytest.mark.asyncio + async def test_subscriber_waits_for_topic( + self, discovery_daemon, discovery_address + ): """Subscriber should wait for topic to appear.""" - received = [] + connected_event = asyncio.Event() - # Create subscriber first (will wait) - def create_subscriber(): + async def subscriber_task(): sub = Subscriber( topic_name="/test/wait_topic", message_type=SampleMessage, @@ -198,14 +199,16 @@ def create_subscriber(): wait_for_topic=True, topic_timeout=5.0, ) - received.append(sub.is_connected) + # _async_connect is called in receive(), which waits for the topic + await sub.receive() # This triggers async wait + connected_event.set() sub.close() - sub_thread = threading.Thread(target=create_subscriber, daemon=True) - sub_thread.start() + # Start subscriber task (will wait for topic) + sub_task = asyncio.create_task(subscriber_task()) # Create publisher after delay - time.sleep(0.5) + await asyncio.sleep(0.5) pub = Publisher( topic_name="/test/wait_topic", message_type=SampleMessage, @@ -213,12 +216,15 @@ def create_subscriber(): discovery_address=discovery_address, ) - sub_thread.join(timeout=6.0) - - assert len(received) == 1 - assert received[0] - - pub.close() + # Wait for subscriber to connect + try: + await asyncio.wait_for(connected_event.wait(), timeout=6.0) + assert connected_event.is_set() + finally: + sub_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await sub_task + pub.close() class TestPubSubIntegration: From de1a5c644ee3728493404e6b0ab1cf0e61344d11 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 13:02:43 -0500 Subject: [PATCH 12/28] add 3.13,3.14 to tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c158050..eb50440 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 From f5fabfcae925abb458f1f6c4120585f2f2e1a05d Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 13:04:03 -0500 Subject: [PATCH 13/28] Added uvloop for better async for UNIX systems --- benchmarks/bench_latency.py | 12 +++++-- benchmarks/bench_throughput.py | 3 +- examples/multi_node_system.py | 3 +- examples/publisher_dict.py | 3 +- examples/publisher_numpy.py | 5 ++- examples/publisher_tensor.py | 5 ++- examples/subscriber_dict.py | 17 ++++------ examples/subscriber_numpy.py | 20 +++++------- examples/subscriber_tensor.py | 20 +++++------- pyproject.toml | 7 +++-- src/cortex/__init__.py | 4 ++- src/cortex/discovery/client.py | 57 ++++++++++++++++++++-------------- src/cortex/utils/__init__.py | 8 ++++- src/cortex/utils/loop.py | 40 ++++++++++++++++++++++++ 14 files changed, 128 insertions(+), 76 deletions(-) create mode 100644 src/cortex/utils/loop.py diff --git a/benchmarks/bench_latency.py b/benchmarks/bench_latency.py index e5fda5d..c2be413 100644 --- a/benchmarks/bench_latency.py +++ b/benchmarks/bench_latency.py @@ -20,6 +20,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) +import cortex from cortex.core.publisher import Publisher from cortex.core.subscriber import Subscriber from cortex.discovery.daemon import DiscoveryDaemon @@ -103,9 +104,16 @@ async def subscriber_main(): message_type=LatencyMessage, node_name="latency_subscriber", wait_for_topic=True, - topic_timeout=10.0, + topic_timeout=30.0, ) + # Wait for topic to be available before signaling ready + if not sub.is_connected: + connected = await sub._async_connect() + if not connected: + results_queue.put({"received": 0, "latencies": [], "error": "timeout"}) + return + # Signal ready ready_event.set() @@ -138,7 +146,7 @@ async def subscriber_main(): } ) - asyncio.run(subscriber_main()) + cortex.run(subscriber_main()) def run_latency_benchmark( diff --git a/benchmarks/bench_throughput.py b/benchmarks/bench_throughput.py index 1fb2fde..e4a64cd 100644 --- a/benchmarks/bench_throughput.py +++ b/benchmarks/bench_throughput.py @@ -16,6 +16,7 @@ import numpy as np +import cortex from cortex import Publisher, Subscriber from cortex.discovery import DiscoveryDaemon from cortex.messages import ArrayMessage @@ -121,7 +122,7 @@ async def run_sub(): except Exception: break - asyncio.run(run_sub()) + cortex.run(run_sub()) sub_thread = threading.Thread(target=subscriber_loop, daemon=True) sub_thread.start() diff --git a/examples/multi_node_system.py b/examples/multi_node_system.py index 8ab1d1f..1160d09 100644 --- a/examples/multi_node_system.py +++ b/examples/multi_node_system.py @@ -23,6 +23,7 @@ import numpy as np +import cortex from cortex import Message, Node from cortex.messages.base import MessageHeader @@ -236,4 +237,4 @@ async def main() -> None: if __name__ == "__main__": with contextlib.suppress(KeyboardInterrupt): - asyncio.run(main()) + cortex.run(main()) diff --git a/examples/publisher_dict.py b/examples/publisher_dict.py index 1baf269..0552b2f 100644 --- a/examples/publisher_dict.py +++ b/examples/publisher_dict.py @@ -22,6 +22,7 @@ import numpy as np +import cortex from cortex import DictMessage, Node @@ -108,4 +109,4 @@ async def main() -> None: if __name__ == "__main__": with contextlib.suppress(KeyboardInterrupt): - asyncio.run(main()) + cortex.run(main()) diff --git a/examples/publisher_numpy.py b/examples/publisher_numpy.py index f6d58fc..bd14a25 100644 --- a/examples/publisher_numpy.py +++ b/examples/publisher_numpy.py @@ -16,10 +16,9 @@ python examples/subscriber_numpy.py """ -import asyncio - import numpy as np +import cortex from cortex import ArrayMessage, Node @@ -84,4 +83,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + cortex.run(main()) diff --git a/examples/publisher_tensor.py b/examples/publisher_tensor.py index 9f87b7f..e17362e 100644 --- a/examples/publisher_tensor.py +++ b/examples/publisher_tensor.py @@ -16,14 +16,13 @@ python examples/subscriber_tensor.py """ -import asyncio - try: import torch except ImportError: print("This example requires PyTorch. Install with: pip install torch") exit(1) +import cortex from cortex import Node, TensorMessage @@ -84,4 +83,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + cortex.run(main()) diff --git a/examples/subscriber_dict.py b/examples/subscriber_dict.py index 9e2f27d..e977310 100644 --- a/examples/subscriber_dict.py +++ b/examples/subscriber_dict.py @@ -19,6 +19,7 @@ import asyncio import contextlib +import cortex from cortex import DictMessage, Node from cortex.messages.base import MessageHeader @@ -28,8 +29,9 @@ class DictSubscriberNode(Node): def __init__(self) -> None: super().__init__(name="dict_subscriber") + # Create a subscriber - connection happens asynchronously in run() print("Waiting for publisher on /robot/state...") - sub = self.create_subscriber( + self.create_subscriber( topic_name="/robot/state", message_type=DictMessage, callback=self._on_state_received, @@ -37,10 +39,7 @@ def __init__(self) -> None: topic_timeout=30.0, ) - if not sub.is_connected: - raise RuntimeError("Failed to connect to topic!") - - print("Connected! Receiving messages...") + print("Subscriber created, will connect when run() is called...") print("Press Ctrl+C to stop") print() @@ -78,11 +77,7 @@ async def main() -> None: """Run the dictionary subscriber example.""" print("Starting dictionary subscriber...") - try: - node = DictSubscriberNode() - except RuntimeError as e: - print(e) - return + node = DictSubscriberNode() try: await node.run() @@ -95,4 +90,4 @@ async def main() -> None: if __name__ == "__main__": with contextlib.suppress(KeyboardInterrupt): - asyncio.run(main()) + cortex.run(main()) diff --git a/examples/subscriber_numpy.py b/examples/subscriber_numpy.py index 417b5a6..369d19c 100644 --- a/examples/subscriber_numpy.py +++ b/examples/subscriber_numpy.py @@ -16,8 +16,7 @@ python examples/subscriber_numpy.py """ -import asyncio - +import cortex from cortex import ArrayMessage, Node from cortex.messages.base import MessageHeader @@ -41,7 +40,7 @@ class ArraySubscriberNode(Node): def __init__(self): super().__init__(name="array_subscriber") - # Create a subscriber + # Create a subscriber - connection happens asynchronously in run() print("Waiting for publisher on /sensor/array_data...") self.sub = self.create_subscriber( topic_name="/sensor/array_data", @@ -51,10 +50,7 @@ def __init__(self): topic_timeout=30.0, ) - if not self.sub.is_connected: - raise RuntimeError("Failed to connect to topic!") - - print("Connected! Receiving messages...") + print("Subscriber created, will connect when run() is called...") print("Press Ctrl+C to stop") print() @@ -63,17 +59,15 @@ async def main(): """Run the subscriber example.""" print("Starting NumPy array subscriber...") + node = ArraySubscriberNode() + try: - node = ArraySubscriberNode() await node.run() - except RuntimeError as e: - print(f"Error: {e}") except KeyboardInterrupt: print("\nShutting down...") finally: - if "node" in locals(): - await node.close() + await node.close() if __name__ == "__main__": - asyncio.run(main()) + cortex.run(main()) diff --git a/examples/subscriber_tensor.py b/examples/subscriber_tensor.py index f35df0f..82a806b 100644 --- a/examples/subscriber_tensor.py +++ b/examples/subscriber_tensor.py @@ -16,14 +16,13 @@ python examples/subscriber_tensor.py """ -import asyncio - try: import torch except ImportError: print("This example requires PyTorch. Install with: pip install torch") exit(1) +import cortex from cortex import Node, TensorMessage from cortex.messages.base import MessageHeader @@ -50,7 +49,7 @@ class TensorSubscriberNode(Node): def __init__(self): super().__init__(name="tensor_subscriber") - # Create a subscriber + # Create a subscriber - connection happens asynchronously in run() print("Waiting for publisher on /model/features...") self.sub = self.create_subscriber( topic_name="/model/features", @@ -60,10 +59,7 @@ def __init__(self): topic_timeout=30.0, ) - if not self.sub.is_connected: - raise RuntimeError("Failed to connect to topic!") - - print("Connected! Receiving messages...") + print("Subscriber created, will connect when run() is called...") print("Press Ctrl+C to stop") print() @@ -73,17 +69,15 @@ async def main(): print("Starting PyTorch tensor subscriber...") print(f"PyTorch version: {torch.__version__}") + node = TensorSubscriberNode() + try: - node = TensorSubscriberNode() await node.run() - except RuntimeError as e: - print(f"Error: {e}") except KeyboardInterrupt: print("\nShutting down...") finally: - if "node" in locals(): - await node.close() + await node.close() if __name__ == "__main__": - asyncio.run(main()) + cortex.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 356cc47..1bc3382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,10 @@ name = "cortex" version = "0.1.0" description = "A lightweight framework using ZeroMQ for inter-process communication" readme = "README.md" -license = {text = "MIT"} +license = {text = "Apache-2.0"} requires-python = ">=3.10" authors = [ - {name = "Cortex Authors"} + {name = "Richeek Das", email = "richeek@seas.upenn.edu"} ] classifiers = [ "Development Status :: 3 - Alpha", @@ -20,11 +20,14 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = [ "pyzmq>=27.0.0", "numpy>=1.26.4", "msgpack>=1.0.0", + "uvloop>=0.19.0; sys_platform != 'win32'", ] [project.optional-dependencies] diff --git a/src/cortex/__init__.py b/src/cortex/__init__.py index fe6529c..f680d7c 100644 --- a/src/cortex/__init__.py +++ b/src/cortex/__init__.py @@ -6,7 +6,7 @@ - Discovery service for automatic topic resolution - Support for numpy arrays, torch tensors, and Python dicts - 64-bit fingerprint hashing for message type identification -- Asyncio-based architecture for cooperative multitasking +- Asyncio-based architecture for cooperative multitasking (with uvloop on Unix) """ from cortex.core.executor import AsyncExecutor, RateExecutor @@ -22,6 +22,7 @@ StringMessage, TensorMessage, ) +from cortex.utils.loop import run __version__ = "0.1.0" __all__ = [ @@ -38,4 +39,5 @@ "StringMessage", "FloatMessage", "IntMessage", + "run", ] diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index e370520..61e4fd5 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -51,7 +51,7 @@ def __init__( self.timeout_ms = timeout_ms self.retries = retries - self._context: zmq.Context | None = None + self._context: zmq.Context = zmq.Context() self._socket: zmq.Socket | None = None self._lock = threading.Lock() @@ -60,45 +60,51 @@ def __init__( self._heartbeat_thread: threading.Thread | None = None self._heartbeat_running = False - def _ensure_connected(self) -> None: - """Ensure we have a connection to the discovery daemon.""" - if self._socket is None: - self._context = zmq.Context() - self._socket = self._context.socket(zmq.REQ) - self._socket.setsockopt(zmq.RCVTIMEO, self.timeout_ms) - self._socket.setsockopt(zmq.SNDTIMEO, self.timeout_ms) - self._socket.setsockopt(zmq.LINGER, 0) # Immediate shutdown - self._socket.connect(self.discovery_address) - - def _reset_connection(self) -> None: - """Reset the connection by closing socket. Next request will reconnect.""" + # Connect immediately + self._connect() + + def _connect(self) -> None: + """Create and connect the socket.""" + self._socket = self._context.socket(zmq.REQ) + self._socket.setsockopt(zmq.RCVTIMEO, self.timeout_ms) + self._socket.setsockopt(zmq.SNDTIMEO, self.timeout_ms) + self._socket.setsockopt(zmq.LINGER, 0) # Immediate shutdown + self._socket.connect(self.discovery_address) + + def _reconnect(self) -> None: + """Reconnect by closing and recreating the socket. + + This is needed because REQ sockets get stuck in a bad state + after a timeout (waiting for reply that will never come). + """ if self._socket: with contextlib.suppress(Exception): self._socket.close() - self._socket = None - self._context = None + self._connect() def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: - """Send a request and wait for response with retries.""" - last_error = None + """Send a request and wait for response.""" + + last_error: Exception | None = None for attempt in range(self.retries): try: with self._lock: - self._ensure_connected() self._socket.send(request.to_bytes()) response_bytes = self._socket.recv() return DiscoveryResponse.from_bytes(response_bytes) except zmq.Again: + # Timeout - need to reconnect because REQ socket is now stuck last_error = TimeoutError( f"Discovery request timed out after {self.timeout_ms}ms" ) logger.warning(f"Request timeout, attempt {attempt + 1}/{self.retries}") - self._reset_connection() + self._reconnect() except zmq.ZMQError as e: + # ZMQ error - reconnect and re-raise last_error = e logger.warning(f"ZMQ error: {e}, attempt {attempt + 1}/{self.retries}") - self._reset_connection() + self._reconnect() raise last_error @@ -179,6 +185,11 @@ def lookup_topic(self, topic_name: str) -> TopicInfo | None: return response.topic_info else: return None + except TimeoutError: + logger.error( + f"Lookup timeout for topic: {topic_name}. Probably Discovery Daemon is not running." + ) + return None except Exception as e: logger.error(f"Failed to lookup topic: {e}") return None @@ -306,10 +317,8 @@ def close(self) -> None: self._socket.close() self._socket = None - if self._context: - with contextlib.suppress(Exception): - self._context.term() - self._context = None + with contextlib.suppress(Exception): + self._context.term() def __enter__(self) -> "DiscoveryClient": return self diff --git a/src/cortex/utils/__init__.py b/src/cortex/utils/__init__.py index 1ff8785..1a93f14 100644 --- a/src/cortex/utils/__init__.py +++ b/src/cortex/utils/__init__.py @@ -1,6 +1,12 @@ """Utilities module for Cortex framework.""" from cortex.utils.hashing import compute_fingerprint +from cortex.utils.loop import run from cortex.utils.serialization import deserialize, serialize -__all__ = ["serialize", "deserialize", "compute_fingerprint"] +__all__ = [ + "serialize", + "deserialize", + "compute_fingerprint", + "run", +] diff --git a/src/cortex/utils/loop.py b/src/cortex/utils/loop.py new file mode 100644 index 0000000..30ab86a --- /dev/null +++ b/src/cortex/utils/loop.py @@ -0,0 +1,40 @@ +""" +Event loop utilities for Cortex. + +Provides uvloop integration for improved async performance on Unix systems. +""" + +import asyncio +import importlib.util +import logging +import sys + +logger = logging.getLogger("cortex.loop") + +# Try to use uvloop on Unix systems +_uvloop_available = ( + sys.platform != "win32" and importlib.util.find_spec("uvloop") is not None +) + + +def run(coro, *, debug: bool = False): + """ + Run a coroutine using uvloop if available. + + This is a replacement for asyncio.run() that uses uvloop on Unix. + + Args: + coro: Coroutine to run + debug: Enable debug mode + + Returns: + The result of the coroutine + """ + if _uvloop_available: + import uvloop + + logger.info("Using uvloop event loop") + return uvloop.run(coro, debug=debug) + + logger.info("Using asyncio event loop") + return asyncio.run(coro, debug=debug) From ffd37a88d2ca3df928d23f306c8ffd08d247ab4a Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 13:10:23 -0500 Subject: [PATCH 14/28] bomb heartbeat, we got no heart --- src/cortex/discovery/client.py | 59 ++-------------------------------- 1 file changed, 3 insertions(+), 56 deletions(-) diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index 61e4fd5..4c76378 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -8,7 +8,6 @@ import asyncio import contextlib import logging -import threading import time import zmq @@ -53,12 +52,6 @@ def __init__( self._context: zmq.Context = zmq.Context() self._socket: zmq.Socket | None = None - self._lock = threading.Lock() - - # Heartbeat thread for registered topics - self._heartbeat_topics: dict[str, bool] = {} - self._heartbeat_thread: threading.Thread | None = None - self._heartbeat_running = False # Connect immediately self._connect() @@ -89,10 +82,9 @@ def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: for attempt in range(self.retries): try: - with self._lock: - self._socket.send(request.to_bytes()) - response_bytes = self._socket.recv() - return DiscoveryResponse.from_bytes(response_bytes) + self._socket.send(request.to_bytes()) + response_bytes = self._socket.recv() + return DiscoveryResponse.from_bytes(response_bytes) except zmq.Again: # Timeout - need to reconnect because REQ socket is now stuck last_error = TimeoutError( @@ -125,8 +117,6 @@ def register_topic(self, topic_info: TopicInfo) -> bool: try: response = self._send_request(request) if response.status == DiscoveryStatus.OK: - # Start heartbeat for this topic - self._start_heartbeat(topic_info.name) logger.info(f"Registered topic: {topic_info.name}") return True else: @@ -146,9 +136,6 @@ def unregister_topic(self, topic_name: str) -> bool: Returns: True if unregistration was successful """ - # Stop heartbeat for this topic - self._stop_heartbeat(topic_name) - request = DiscoveryRequest( command=DiscoveryCommand.UNREGISTER_TOPIC, topic_name=topic_name ) @@ -270,48 +257,8 @@ def list_topics(self) -> list[TopicInfo]: logger.error(f"Failed to list topics: {e}") return [] - def _start_heartbeat(self, topic_name: str) -> None: - """Start sending heartbeats for a topic.""" - self._heartbeat_topics[topic_name] = True - - if not self._heartbeat_running: - self._heartbeat_running = True - self._heartbeat_thread = threading.Thread( - target=self._heartbeat_loop, daemon=True - ) - self._heartbeat_thread.start() - - def _stop_heartbeat(self, topic_name: str) -> None: - """Stop sending heartbeats for a topic.""" - self._heartbeat_topics.pop(topic_name, None) - - def _heartbeat_loop(self) -> None: - """Background thread that sends heartbeats for registered topics.""" - while self._heartbeat_running and self._heartbeat_topics: - for topic_name in list(self._heartbeat_topics.keys()): - if not self._heartbeat_running: - break - if topic_name in self._heartbeat_topics: - self._send_heartbeat(topic_name) - - time.sleep(10.0) # Heartbeat interval - - def _send_heartbeat(self, topic_name: str) -> None: - """Send a heartbeat for a topic.""" - request = DiscoveryRequest( - command=DiscoveryCommand.HEARTBEAT, topic_name=topic_name - ) - - try: - self._send_request(request) - except Exception as e: - logger.warning(f"Failed to send heartbeat for {topic_name}: {e}") - def close(self) -> None: """Close the client connection.""" - self._heartbeat_running = False - self._heartbeat_topics.clear() - if self._socket: with contextlib.suppress(Exception): self._socket.close() From c7c657ae8d80d7eb00ad9521fdc569ab2af56511 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 16:02:02 -0500 Subject: [PATCH 15/28] Python 3.14 CI test hangs. need to debug --- src/cortex/discovery/client.py | 9 ++++++--- tests/conftest.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index 4c76378..cc59424 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -8,6 +8,7 @@ import asyncio import contextlib import logging +import threading import time import zmq @@ -52,6 +53,7 @@ def __init__( self._context: zmq.Context = zmq.Context() self._socket: zmq.Socket | None = None + self._lock = threading.Lock() # Connect immediately self._connect() @@ -82,9 +84,10 @@ def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: for attempt in range(self.retries): try: - self._socket.send(request.to_bytes()) - response_bytes = self._socket.recv() - return DiscoveryResponse.from_bytes(response_bytes) + with self._lock: + self._socket.send(request.to_bytes()) + response_bytes = self._socket.recv() + return DiscoveryResponse.from_bytes(response_bytes) except zmq.Again: # Timeout - need to reconnect because REQ socket is now stuck last_error = TimeoutError( diff --git a/tests/conftest.py b/tests/conftest.py index c3dd5b2..f34794e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,7 @@ def temp_ipc_address(tmp_path) -> str: @pytest.fixture def discovery_address(tmp_path) -> str: """Provide a discovery daemon address.""" - return f"ipc://{tmp_path}/cortex_discovery" + return f"ipc://{tmp_path}/cortex/discovery.sock" @pytest.fixture From 8116f1e8f0e4648bcdcb71625c7b4d6215a58fb6 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 16:19:38 -0500 Subject: [PATCH 16/28] made pub async as well, since it was using async zmq contexts --- src/cortex/core/publisher.py | 4 ++-- src/cortex/discovery/client.py | 9 +++------ tests/test_node.py | 6 ++++-- tests/test_pubsub.py | 20 ++++++++++++-------- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index 9c2bbf3..5cff1ba 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -150,7 +150,7 @@ def _register_with_discovery(self) -> None: except Exception as e: logger.warning(f"Could not connect to discovery daemon: {e}") - def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: + async def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: """ Publish a message (non-blocking). @@ -174,7 +174,7 @@ def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: data = message.to_bytes() # Send with topic name as first frame for filtering - self._socket.send_multipart( + await self._socket.send_multipart( [self.topic_name.encode("utf-8"), data], flags=flags ) diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index cc59424..4c76378 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -8,7 +8,6 @@ import asyncio import contextlib import logging -import threading import time import zmq @@ -53,7 +52,6 @@ def __init__( self._context: zmq.Context = zmq.Context() self._socket: zmq.Socket | None = None - self._lock = threading.Lock() # Connect immediately self._connect() @@ -84,10 +82,9 @@ def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: for attempt in range(self.retries): try: - with self._lock: - self._socket.send(request.to_bytes()) - response_bytes = self._socket.recv() - return DiscoveryResponse.from_bytes(response_bytes) + self._socket.send(request.to_bytes()) + response_bytes = self._socket.recv() + return DiscoveryResponse.from_bytes(response_bytes) except zmq.Again: # Timeout - need to reconnect because REQ socket is now stuck last_error = TimeoutError( diff --git a/tests/test_node.py b/tests/test_node.py index 765c803..a8a7a79 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -112,7 +112,9 @@ async def callback(msg: SensorData, header: MessageHeader) -> None: run_task = asyncio.create_task(sub_node.run()) # Publish data - pub.publish(SensorData(timestamp=time.time(), value=42.0, sensor_id="sensor_1")) + await pub.publish( + SensorData(timestamp=time.time(), value=42.0, sensor_id="sensor_1") + ) # Wait for message to be received with contextlib.suppress(asyncio.TimeoutError): @@ -180,7 +182,7 @@ async def callback(msg: IntMessage, header: MessageHeader) -> None: run_task = asyncio.create_task(sub_node.run()) # Publish - pub.publish(IntMessage(data=123)) + await pub.publish(IntMessage(data=123)) # Wait for message with contextlib.suppress(asyncio.TimeoutError): diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index e97afed..2a589d1 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -60,7 +60,10 @@ def test_publisher_registers_with_discovery( pub.close() - def test_publisher_publishes_messages(self, discovery_daemon, discovery_address): + @pytest.mark.asyncio + async def test_publisher_publishes_messages( + self, discovery_daemon, discovery_address + ): """Publisher should publish messages.""" pub = Publisher( topic_name="/test/publish", @@ -70,14 +73,15 @@ def test_publisher_publishes_messages(self, discovery_daemon, discovery_address) ) msg = SampleMessage(value=42, name="test") - success = pub.publish(msg) + success = await pub.publish(msg) assert success assert pub.publish_count == 1 pub.close() - def test_publisher_type_checking(self, discovery_daemon, discovery_address): + @pytest.mark.asyncio + async def test_publisher_type_checking(self, discovery_daemon, discovery_address): """Publisher should reject wrong message types.""" pub = Publisher( topic_name="/test/typecheck", @@ -89,7 +93,7 @@ def test_publisher_type_checking(self, discovery_daemon, discovery_address): wrong_msg = StringMessage(data="wrong type") with pytest.raises(TypeError): - pub.publish(wrong_msg) + await pub.publish(wrong_msg) pub.close() @@ -164,7 +168,7 @@ async def callback(msg: SampleMessage, header: MessageHeader) -> None: run_task = asyncio.create_task(sub.run()) # Publish message - pub.publish(SampleMessage(value=42, name="test")) + await pub.publish(SampleMessage(value=42, name="test")) # Wait for message to be received with contextlib.suppress(asyncio.TimeoutError): @@ -263,7 +267,7 @@ async def callback(msg: SampleMessage, header: MessageHeader) -> None: # Publish multiple messages for i in range(10): - pub.publish(SampleMessage(value=i, name=f"msg_{i}")) + await pub.publish(SampleMessage(value=i, name=f"msg_{i}")) # Wait for all messages with contextlib.suppress(asyncio.TimeoutError): @@ -313,7 +317,7 @@ async def callback(msg: ArrayMessage, header: MessageHeader) -> None: # Send large array arr = np.random.randn(100, 100).astype(np.float32) - pub.publish(ArrayMessage(data=arr)) + await pub.publish(ArrayMessage(data=arr)) # Wait for message with contextlib.suppress(asyncio.TimeoutError): @@ -368,7 +372,7 @@ async def callback(msg: DictMessage, header: MessageHeader) -> None: "nested": {"a": 1, "b": 2}, "array": np.array([1.0, 2.0, 3.0]), } - pub.publish(DictMessage(data=data)) + await pub.publish(DictMessage(data=data)) # Wait for message with contextlib.suppress(asyncio.TimeoutError): From 5425b648c49e03c7c047711206a2cd7dbd449512 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 16:19:38 -0500 Subject: [PATCH 17/28] made pub async as well, since it was using async zmq contexts --- src/cortex/core/executor.py | 13 ++++--------- src/cortex/core/publisher.py | 21 ++++++++++++--------- src/cortex/core/subscriber.py | 2 -- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/cortex/core/executor.py b/src/cortex/core/executor.py index 135ce2c..6fbdf1b 100644 --- a/src/cortex/core/executor.py +++ b/src/cortex/core/executor.py @@ -86,7 +86,7 @@ async def _run_impl(self, *args, **kwargs) -> None: break except Exception as e: logger.error(f"Error in AsyncExecutor: {e}") - await asyncio.sleep(0.001) + await asyncio.sleep(0) class RateExecutor(BaseExecutor): @@ -141,11 +141,6 @@ async def _run_impl(self, *args, **kwargs) -> None: break except Exception as e: logger.error(f"Error in RateExecutor: {e}") - await asyncio.sleep(0.001) - - # Sleep until next execution time (with small max to stay responsive) - sleep_time = min( - max(0, next_exec_time - time.perf_counter()), - 0.001, # Max sleep to stay responsive - ) - await asyncio.sleep(sleep_time) + await asyncio.sleep(0) + + await asyncio.sleep(max(0, next_exec_time - time.perf_counter())) diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index 5cff1ba..6dda7fc 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -22,17 +22,17 @@ logger = logging.getLogger("cortex.publisher") -def generate_ipc_address(topic_name: str) -> str: +def generate_ipc_address(topic_name: str, node_name: str) -> str: """ - Generate a unique IPC address for a topic. + Generate a unique IPC address for a topic and node. Uses a hash of the topic name to create a valid filesystem path. """ # Create a safe filename from topic name safe_name = topic_name.replace("/", "_").lstrip("_") - # Add hash suffix for uniqueness - hash_suffix = hashlib.md5(topic_name.encode()).hexdigest()[:8] + # Add hash suffix for uniqueness (topic name + node name) + hash_suffix = hashlib.md5(f"{node_name}:{topic_name}".encode()).hexdigest()[:8] # Ensure the directory exists ipc_dir = "/tmp/cortex/topics" @@ -65,7 +65,7 @@ def __init__( discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, queue_size: int = 10, auto_register: bool = True, - context: zmq.asyncio.Context | None = None, + context: zmq.asyncio.Context | zmq.Context | None = None, ): """ Initialize the publisher. @@ -77,7 +77,7 @@ def __init__( discovery_address: Address of the discovery daemon queue_size: High-water mark for outgoing messages auto_register: Whether to automatically register with discovery daemon - context: Shared ZMQ async context from Node + context: Shared ZMQ async context or sync context (optional) """ self.topic_name = topic_name self.message_type = message_type @@ -86,11 +86,14 @@ def __init__( self.queue_size = queue_size # Generate IPC address for this topic - self.address = generate_ipc_address(topic_name) + self.address = generate_ipc_address(topic_name, node_name) # ZMQ setup - context provided by Node - self._context: zmq.asyncio.Context = context or zmq.asyncio.Context() - self._socket: zmq.asyncio.Socket | None = None + # if context is async context, convert to sync context + self._context: zmq.asyncio.Context | zmq.Context = context or zmq.Context() + if isinstance(self._context, zmq.asyncio.Context): + self._context: zmq.Context = zmq.Context(self._context) + self._socket: zmq.Socket | None = None # Discovery client self._discovery_client: DiscoveryClient | None = None diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index 9463f1c..501ebb7 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -217,8 +217,6 @@ async def receive(self) -> tuple[Message, MessageHeader] | None: return message, header - except zmq.Again: - return None except asyncio.CancelledError: raise except Exception as e: From 0b7028e5c20d548c041a52d0b4e2cbbf1af8e30f Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 29 Nov 2025 20:41:35 -0500 Subject: [PATCH 18/28] Bad things happen if publisher is async. It is nonblocking by default --- src/cortex/core/publisher.py | 4 ++-- tests/test_node.py | 6 ++---- tests/test_pubsub.py | 20 ++++++++------------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index 6dda7fc..a35baf0 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -153,7 +153,7 @@ def _register_with_discovery(self) -> None: except Exception as e: logger.warning(f"Could not connect to discovery daemon: {e}") - async def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: + def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: """ Publish a message (non-blocking). @@ -177,7 +177,7 @@ async def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: data = message.to_bytes() # Send with topic name as first frame for filtering - await self._socket.send_multipart( + self._socket.send_multipart( [self.topic_name.encode("utf-8"), data], flags=flags ) diff --git a/tests/test_node.py b/tests/test_node.py index a8a7a79..765c803 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -112,9 +112,7 @@ async def callback(msg: SensorData, header: MessageHeader) -> None: run_task = asyncio.create_task(sub_node.run()) # Publish data - await pub.publish( - SensorData(timestamp=time.time(), value=42.0, sensor_id="sensor_1") - ) + pub.publish(SensorData(timestamp=time.time(), value=42.0, sensor_id="sensor_1")) # Wait for message to be received with contextlib.suppress(asyncio.TimeoutError): @@ -182,7 +180,7 @@ async def callback(msg: IntMessage, header: MessageHeader) -> None: run_task = asyncio.create_task(sub_node.run()) # Publish - await pub.publish(IntMessage(data=123)) + pub.publish(IntMessage(data=123)) # Wait for message with contextlib.suppress(asyncio.TimeoutError): diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 2a589d1..e97afed 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -60,10 +60,7 @@ def test_publisher_registers_with_discovery( pub.close() - @pytest.mark.asyncio - async def test_publisher_publishes_messages( - self, discovery_daemon, discovery_address - ): + def test_publisher_publishes_messages(self, discovery_daemon, discovery_address): """Publisher should publish messages.""" pub = Publisher( topic_name="/test/publish", @@ -73,15 +70,14 @@ async def test_publisher_publishes_messages( ) msg = SampleMessage(value=42, name="test") - success = await pub.publish(msg) + success = pub.publish(msg) assert success assert pub.publish_count == 1 pub.close() - @pytest.mark.asyncio - async def test_publisher_type_checking(self, discovery_daemon, discovery_address): + def test_publisher_type_checking(self, discovery_daemon, discovery_address): """Publisher should reject wrong message types.""" pub = Publisher( topic_name="/test/typecheck", @@ -93,7 +89,7 @@ async def test_publisher_type_checking(self, discovery_daemon, discovery_address wrong_msg = StringMessage(data="wrong type") with pytest.raises(TypeError): - await pub.publish(wrong_msg) + pub.publish(wrong_msg) pub.close() @@ -168,7 +164,7 @@ async def callback(msg: SampleMessage, header: MessageHeader) -> None: run_task = asyncio.create_task(sub.run()) # Publish message - await pub.publish(SampleMessage(value=42, name="test")) + pub.publish(SampleMessage(value=42, name="test")) # Wait for message to be received with contextlib.suppress(asyncio.TimeoutError): @@ -267,7 +263,7 @@ async def callback(msg: SampleMessage, header: MessageHeader) -> None: # Publish multiple messages for i in range(10): - await pub.publish(SampleMessage(value=i, name=f"msg_{i}")) + pub.publish(SampleMessage(value=i, name=f"msg_{i}")) # Wait for all messages with contextlib.suppress(asyncio.TimeoutError): @@ -317,7 +313,7 @@ async def callback(msg: ArrayMessage, header: MessageHeader) -> None: # Send large array arr = np.random.randn(100, 100).astype(np.float32) - await pub.publish(ArrayMessage(data=arr)) + pub.publish(ArrayMessage(data=arr)) # Wait for message with contextlib.suppress(asyncio.TimeoutError): @@ -372,7 +368,7 @@ async def callback(msg: DictMessage, header: MessageHeader) -> None: "nested": {"a": 1, "b": 2}, "array": np.array([1.0, 2.0, 3.0]), } - await pub.publish(DictMessage(data=data)) + pub.publish(DictMessage(data=data)) # Wait for message with contextlib.suppress(asyncio.TimeoutError): From 3faaa20ccca150ee684aa7711133babee39b8609 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Tue, 2 Dec 2025 22:07:27 -0500 Subject: [PATCH 19/28] fix uniqueness of ipc address --- src/cortex/core/publisher.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index a35baf0..c3317ca 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -6,10 +6,10 @@ """ import contextlib -import hashlib import logging import os import time +import uuid import zmq import zmq.asyncio @@ -25,20 +25,17 @@ def generate_ipc_address(topic_name: str, node_name: str) -> str: """ Generate a unique IPC address for a topic and node. - - Uses a hash of the topic name to create a valid filesystem path. """ - # Create a safe filename from topic name - safe_name = topic_name.replace("/", "_").lstrip("_") + # Create a safe filename from topic name and node name + safe_name = node_name + "__" + topic_name.replace("/", "_").lstrip("_") - # Add hash suffix for uniqueness (topic name + node name) - hash_suffix = hashlib.md5(f"{node_name}:{topic_name}".encode()).hexdigest()[:8] + uuid_str = str(uuid.uuid4())[:8] # Generate a unique identifier to make it unique # Ensure the directory exists ipc_dir = "/tmp/cortex/topics" os.makedirs(ipc_dir, exist_ok=True) - return f"ipc://{ipc_dir}/{safe_name}_{hash_suffix}.sock" + return f"ipc://{ipc_dir}/{safe_name}.{uuid_str}.sock" class Publisher: @@ -92,7 +89,7 @@ def __init__( # if context is async context, convert to sync context self._context: zmq.asyncio.Context | zmq.Context = context or zmq.Context() if isinstance(self._context, zmq.asyncio.Context): - self._context: zmq.Context = zmq.Context(self._context) + self._context: zmq.Context = zmq.Context(self._context) # publishers are sync self._socket: zmq.Socket | None = None # Discovery client From ee2cc1dc3158ef78ed36a1e4a6d7e62d80aeeacd Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Wed, 3 Dec 2025 12:21:10 -0500 Subject: [PATCH 20/28] improved logging. discovery daemon is now single threaded. heartbeats have been removed for now --- src/cortex/core/publisher.py | 14 +-- src/cortex/discovery/daemon.py | 169 +++++++++++-------------------- src/cortex/discovery/protocol.py | 1 - src/cortex/utils/__init__.py | 3 + src/cortex/utils/logging.py | 127 +++++++++++++++++++++++ tests/conftest.py | 3 - 6 files changed, 195 insertions(+), 122 deletions(-) create mode 100644 src/cortex/utils/logging.py diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index c3317ca..7e588d2 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -9,7 +9,6 @@ import logging import os import time -import uuid import zmq import zmq.asyncio @@ -24,18 +23,19 @@ def generate_ipc_address(topic_name: str, node_name: str) -> str: """ - Generate a unique IPC address for a topic and node. + Generate a IPC address for a topic and node. + + Assumption is that node_name and topic_name form a unique combination. + So there are no two publishers with the same node_name publishing the same topic_name. """ # Create a safe filename from topic name and node name safe_name = node_name + "__" + topic_name.replace("/", "_").lstrip("_") - uuid_str = str(uuid.uuid4())[:8] # Generate a unique identifier to make it unique - # Ensure the directory exists ipc_dir = "/tmp/cortex/topics" os.makedirs(ipc_dir, exist_ok=True) - return f"ipc://{ipc_dir}/{safe_name}.{uuid_str}.sock" + return f"ipc://{ipc_dir}/{safe_name}.sock" class Publisher: @@ -89,7 +89,9 @@ def __init__( # if context is async context, convert to sync context self._context: zmq.asyncio.Context | zmq.Context = context or zmq.Context() if isinstance(self._context, zmq.asyncio.Context): - self._context: zmq.Context = zmq.Context(self._context) # publishers are sync + self._context: zmq.Context = zmq.Context( + self._context + ) # publishers are sync self._socket: zmq.Socket | None = None # Discovery client diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index dbdb8d5..df1d275 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -5,14 +5,11 @@ of all active topics. Publishers register their topics, and subscribers query for topic addresses. -Default IPC address: ipc:///tmp/cortex_discovery +Default IPC address: ipc:///tmp/cortex/discovery.sock """ import contextlib -import logging import os -import threading -import time import zmq @@ -23,12 +20,10 @@ DiscoveryStatus, TopicInfo, ) +from cortex.utils.logging import get_logger, set_log_level -# Configure logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" -) -logger = logging.getLogger("cortex.discovery") +# Get logger for this module +logger = get_logger("cortex.discovery") # Default discovery address @@ -45,32 +40,24 @@ class DiscoveryDaemon: def __init__( self, address: str = DEFAULT_DISCOVERY_ADDRESS, - cleanup_interval: float = 30.0, ): """ Initialize the discovery daemon. Args: - address: ZMQ address to bind to (default: ipc:///tmp/cortex_discovery) - cleanup_interval: Interval in seconds for cleaning up stale topics + address: ZMQ address to bind to (default: ipc:///tmp/cortex/discovery.sock) """ self.address = address - self.cleanup_interval = cleanup_interval # Topic registry: topic_name -> TopicInfo self._topics: dict[str, TopicInfo] = {} - self._topics_lock = threading.Lock() - - # Last heartbeat time for each topic - self._heartbeats: dict[str, float] = {} # ZMQ context and socket self._context: zmq.Context | None = None self._socket: zmq.Socket | None = None - # Control flags + # Control flag self._running = False - self._shutdown_event = threading.Event() def _ensure_ipc_path(self) -> None: """Ensure the IPC socket directory exists.""" @@ -93,14 +80,19 @@ def start(self) -> None: self._socket = self._context.socket(zmq.REP) self._socket.bind(self.address) + #! We do not set a high water mark on the socket. + #! It is 1000 by default, which is reasonable for our use case. + # Set socket options for responsiveness self._socket.setsockopt(zmq.RCVTIMEO, 1000) # 1 second timeout self._socket.setsockopt(zmq.LINGER, 0) # Immediate shutdown self._running = True - self._shutdown_event.clear() - logger.info("Discovery daemon started") + logger.info("=" * 50) + logger.info("DISCOVERY DAEMON STARTED") + logger.info(" Address: %s", self.address) + logger.info("=" * 50) try: self._run_loop() @@ -111,27 +103,19 @@ def start(self) -> None: def _run_loop(self) -> None: """Main event loop.""" - last_cleanup = time.time() - - while self._running and not self._shutdown_event.is_set(): + while self._running: try: # Try to receive a request try: - request_bytes = self._socket.recv(zmq.NOBLOCK) + request_bytes = self._socket.recv(copy=False) + + # Process the request + response = self._handle_request(request_bytes) + self._socket.send(response.to_bytes()) except zmq.Again: # No message available, continue - time.sleep(0.01) - - # Periodic cleanup - if time.time() - last_cleanup > self.cleanup_interval: - self._cleanup_stale_topics() - last_cleanup = time.time() continue - # Process the request - response = self._handle_request(request_bytes) - self._socket.send(response.to_bytes()) - except Exception as e: logger.error(f"Error in discovery loop: {e}") # Send error response if we received a request @@ -160,8 +144,6 @@ def _handle_request(self, request_bytes: bytes) -> DiscoveryResponse: return self._handle_lookup(request) elif request.command == DiscoveryCommand.LIST_TOPICS: return self._handle_list() - elif request.command == DiscoveryCommand.HEARTBEAT: - return self._handle_heartbeat(request) elif request.command == DiscoveryCommand.SHUTDOWN: return self._handle_shutdown() else: @@ -180,20 +162,23 @@ def _handle_register(self, request: DiscoveryRequest) -> DiscoveryResponse: topic_name = request.topic_info.name - with self._topics_lock: - if topic_name in self._topics: - # Allow re-registration from same publisher - existing = self._topics[topic_name] - if existing.publisher_node != request.topic_info.publisher_node: - return DiscoveryResponse( - status=DiscoveryStatus.ALREADY_EXISTS, - message=f"Topic {topic_name} already registered by {existing.publisher_node}", - ) + if topic_name in self._topics: + # Allow re-registration from same publisher + existing = self._topics[topic_name] + if existing.publisher_node != request.topic_info.publisher_node: + return DiscoveryResponse( + status=DiscoveryStatus.ALREADY_EXISTS, + message=f"Topic {topic_name} already registered by {existing.publisher_node}", + ) - self._topics[topic_name] = request.topic_info - self._heartbeats[topic_name] = time.time() + self._topics[topic_name] = request.topic_info - logger.info(f"Registered topic: {topic_name} at {request.topic_info.address}") + logger.info("-" * 50) + logger.info("REGISTER topic: %s", topic_name) + logger.info(" Address: %s", request.topic_info.address) + logger.info(" Publisher: %s", request.topic_info.publisher_node) + logger.info(" Type: %s", request.topic_info.message_type) + logger.info(" Fingerprint: %d", request.topic_info.fingerprint) return DiscoveryResponse( status=DiscoveryStatus.OK, message=f"Registered topic: {topic_name}" @@ -211,17 +196,16 @@ def _handle_unregister(self, request: DiscoveryRequest) -> DiscoveryResponse: message="Missing topic name in unregister request", ) - with self._topics_lock: - if topic_name not in self._topics: - return DiscoveryResponse( - status=DiscoveryStatus.NOT_FOUND, - message=f"Topic {topic_name} not found", - ) + if topic_name not in self._topics: + return DiscoveryResponse( + status=DiscoveryStatus.NOT_FOUND, + message=f"Topic {topic_name} not found", + ) - del self._topics[topic_name] - self._heartbeats.pop(topic_name, None) + del self._topics[topic_name] - logger.info(f"Unregistered topic: {topic_name}") + logger.info("-" * 50) + logger.info("UNREGISTER topic: %s", topic_name) return DiscoveryResponse( status=DiscoveryStatus.OK, message=f"Unregistered topic: {topic_name}" @@ -237,12 +221,14 @@ def _handle_lookup(self, request: DiscoveryRequest) -> DiscoveryResponse: message="Missing topic_name in lookup request", ) - with self._topics_lock: - topic_info = self._topics.get(topic_name) + topic_info = self._topics.get(topic_name) + logger.info("-" * 50) if topic_info: + logger.info("LOOKUP topic: %s -> FOUND", topic_name) return DiscoveryResponse(status=DiscoveryStatus.OK, topic_info=topic_info) else: + logger.info("LOOKUP topic: %s -> NOT FOUND", topic_name) return DiscoveryResponse( status=DiscoveryStatus.NOT_FOUND, message=f"Topic {topic_name} not found", @@ -250,59 +236,24 @@ def _handle_lookup(self, request: DiscoveryRequest) -> DiscoveryResponse: def _handle_list(self) -> DiscoveryResponse: """Handle list all topics.""" - with self._topics_lock: - topics = list(self._topics.values()) + topics = list(self._topics.values()) - return DiscoveryResponse(status=DiscoveryStatus.OK, topics=topics) + logger.info("-" * 50) + logger.info("LIST topics: %d registered", len(topics)) - def _handle_heartbeat(self, request: DiscoveryRequest) -> DiscoveryResponse: - """Handle heartbeat from publisher.""" - topic_name = request.topic_name - - if not topic_name: - return DiscoveryResponse( - status=DiscoveryStatus.ERROR, - message="Missing topic_name in heartbeat request", - ) - - with self._topics_lock: - if topic_name in self._topics: - self._heartbeats[topic_name] = time.time() - return DiscoveryResponse(status=DiscoveryStatus.OK) - else: - return DiscoveryResponse( - status=DiscoveryStatus.NOT_FOUND, - message=f"Topic {topic_name} not registered", - ) + return DiscoveryResponse(status=DiscoveryStatus.OK, topics=topics) def _handle_shutdown(self) -> DiscoveryResponse: """Handle shutdown request.""" - logger.info("Received shutdown command") self._running = False - self._shutdown_event.set() + logger.info("-" * 50) + logger.info("SHUTDOWN command received") return DiscoveryResponse(status=DiscoveryStatus.OK, message="Shutting down") - def _cleanup_stale_topics(self) -> None: - """Remove topics that haven't sent a heartbeat recently.""" - stale_threshold = time.time() - (self.cleanup_interval * 3) - - with self._topics_lock: - stale_topics = [ - name - for name, last_beat in self._heartbeats.items() - if last_beat < stale_threshold - ] - - for topic_name in stale_topics: - logger.warning(f"Removing stale topic: {topic_name}") - del self._topics[topic_name] - del self._heartbeats[topic_name] - def stop(self) -> None: """Stop the discovery daemon.""" logger.info("Stopping discovery daemon") self._running = False - self._shutdown_event.set() if self._socket: try: @@ -325,7 +276,9 @@ def stop(self) -> None: with contextlib.suppress(Exception): os.remove(path) - logger.info("Discovery daemon stopped") + logger.info("=" * 50) + logger.info("DISCOVERY DAEMON STOPPED") + logger.info("=" * 50) def main(): @@ -338,12 +291,6 @@ def main(): default=DEFAULT_DISCOVERY_ADDRESS, help=f"ZMQ address to bind to (default: {DEFAULT_DISCOVERY_ADDRESS})", ) - parser.add_argument( - "--cleanup-interval", - type=float, - default=30.0, - help="Interval for cleaning up stale topics (default: 30s)", - ) parser.add_argument( "--log-level", default="INFO", @@ -354,12 +301,10 @@ def main(): args = parser.parse_args() # Set log level - logging.getLogger().setLevel(getattr(logging, args.log_level)) + set_log_level(logger, args.log_level) # Create and run daemon - daemon = DiscoveryDaemon( - address=args.address, cleanup_interval=args.cleanup_interval - ) + daemon = DiscoveryDaemon(address=args.address) daemon.start() diff --git a/src/cortex/discovery/protocol.py b/src/cortex/discovery/protocol.py index 53e48fb..a13eeda 100644 --- a/src/cortex/discovery/protocol.py +++ b/src/cortex/discovery/protocol.py @@ -17,7 +17,6 @@ class DiscoveryCommand(IntEnum): UNREGISTER_TOPIC = 2 LOOKUP_TOPIC = 3 LIST_TOPICS = 4 - HEARTBEAT = 5 SHUTDOWN = 99 diff --git a/src/cortex/utils/__init__.py b/src/cortex/utils/__init__.py index 1a93f14..7963b6f 100644 --- a/src/cortex/utils/__init__.py +++ b/src/cortex/utils/__init__.py @@ -1,6 +1,7 @@ """Utilities module for Cortex framework.""" from cortex.utils.hashing import compute_fingerprint +from cortex.utils.logging import get_logger, set_log_level from cortex.utils.loop import run from cortex.utils.serialization import deserialize, serialize @@ -9,4 +10,6 @@ "deserialize", "compute_fingerprint", "run", + "get_logger", + "set_log_level", ] diff --git a/src/cortex/utils/logging.py b/src/cortex/utils/logging.py new file mode 100644 index 0000000..5afd9ce --- /dev/null +++ b/src/cortex/utils/logging.py @@ -0,0 +1,127 @@ +""" +Shared logging configuration for Cortex. + +Provides colored console logging with consistent formatting across all modules. +""" + +import logging +import sys + +# ANSI color codes (no external dependencies) +RESET = "\033[0m" +BOLD = "\033[1m" + +# Foreground colors +BLACK = "\033[30m" +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +BLUE = "\033[34m" +MAGENTA = "\033[35m" +CYAN = "\033[36m" +WHITE = "\033[37m" + +# Bright foreground colors +BRIGHT_RED = "\033[91m" +BRIGHT_GREEN = "\033[92m" +BRIGHT_YELLOW = "\033[93m" +BRIGHT_BLUE = "\033[94m" +BRIGHT_MAGENTA = "\033[95m" +BRIGHT_CYAN = "\033[96m" + + +class ColoredFormatter(logging.Formatter): + """ + Custom formatter with colors for different log levels and message types. + """ + + LEVEL_COLORS = { + "DEBUG": CYAN, + "INFO": GREEN, + "WARNING": YELLOW, + "ERROR": RED, + "CRITICAL": BRIGHT_RED, + } + + def format(self, record: logging.LogRecord) -> str: + # Color the level name + levelname = record.levelname + if levelname in self.LEVEL_COLORS: + record.levelname = f"{self.LEVEL_COLORS[levelname]}{levelname:<5}{RESET}" + + # Format the message first + formatted = super().format(record) + + # Restore original levelname for potential reuse + record.levelname = levelname + + # Apply message-specific colors + msg = record.getMessage() + + # Blue separators + if msg.startswith("-") or msg.startswith("="): + return self._colorize_line(formatted, BLUE) + + return formatted + + def _colorize_line(self, line: str, color: str, bold: bool = False) -> str: + """Apply color to the message portion of the log line.""" + # Find the last | separator and colorize everything after it + parts = line.rsplit("|", 1) + if len(parts) == 2: + prefix, message = parts + if bold: + return f"{prefix}|{color}{BOLD}{message}{RESET}" + return f"{prefix}|{color}{message}{RESET}" + if bold: + return f"{color}{BOLD}{line}{RESET}" + return f"{color}{line}{RESET}" + + +def get_logger(name: str, level: int = logging.INFO) -> logging.Logger: + """ + Get a configured logger with colored output. + + Args: + name: Logger name (e.g., "cortex.discovery") + level: Logging level (default: INFO) + + Returns: + Configured logger instance + """ + logger = logging.getLogger(name) + + # Avoid adding duplicate handlers + if logger.handlers: + return logger + + logger.setLevel(level) + logger.propagate = False + + # Create console handler with colored formatter + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(level) + handler.setFormatter( + ColoredFormatter( + fmt="%(asctime)s | %(levelname)s | %(message)s", + datefmt="%H:%M:%S", + ) + ) + + logger.addHandler(handler) + + return logger + + +def set_log_level(logger: logging.Logger, level: str) -> None: + """ + Set the log level for a logger and its handlers. + + Args: + logger: The logger to configure + level: Log level name ("DEBUG", "INFO", "WARNING", "ERROR") + """ + log_level = getattr(logging, level.upper(), logging.INFO) + logger.setLevel(log_level) + for handler in logger.handlers: + handler.setLevel(log_level) diff --git a/tests/conftest.py b/tests/conftest.py index f34794e..cb100f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,10 +77,7 @@ def run_daemon(): def stop(self) -> None: """Stop the discovery daemon.""" if self._daemon: - # Signal shutdown - daemon's start() will call stop() which - # closes socket and terminates context in the same thread self._daemon._running = False - self._daemon._shutdown_event.set() # Wait for thread to finish (daemon cleans up its own context) if self._thread: From 930549c4ba5b5839808c57041632857f69985926 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Wed, 3 Dec 2025 12:37:34 -0500 Subject: [PATCH 21/28] give daemon enough time to kts --- src/cortex/discovery/daemon.py | 8 ++++++-- tests/conftest.py | 1 + tests/test_discovery.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index df1d275..a494fa0 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -10,6 +10,7 @@ import contextlib import os +import threading import zmq @@ -56,8 +57,9 @@ def __init__( self._context: zmq.Context | None = None self._socket: zmq.Socket | None = None - # Control flag + # Control flags self._running = False + self._shutdown_event = threading.Event() def _ensure_ipc_path(self) -> None: """Ensure the IPC socket directory exists.""" @@ -88,6 +90,7 @@ def start(self) -> None: self._socket.setsockopt(zmq.LINGER, 0) # Immediate shutdown self._running = True + self._shutdown_event.clear() logger.info("=" * 50) logger.info("DISCOVERY DAEMON STARTED") @@ -103,7 +106,7 @@ def start(self) -> None: def _run_loop(self) -> None: """Main event loop.""" - while self._running: + while self._running and not self._shutdown_event.is_set(): try: # Try to receive a request try: @@ -253,6 +256,7 @@ def _handle_shutdown(self) -> DiscoveryResponse: def stop(self) -> None: """Stop the discovery daemon.""" logger.info("Stopping discovery daemon") + self._shutdown_event.set() self._running = False if self._socket: diff --git a/tests/conftest.py b/tests/conftest.py index cb100f9..2bf2154 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -78,6 +78,7 @@ def stop(self) -> None: """Stop the discovery daemon.""" if self._daemon: self._daemon._running = False + self._daemon._shutdown_event.set() # Wait for thread to finish (daemon cleans up its own context) if self._thread: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index e0f2eb8..97ac9aa 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -97,7 +97,7 @@ def test_daemon_starts_and_stops(self, discovery_address): time.sleep(0.2) # Let it start daemon.stop() - thread.join(timeout=2.0) + thread.join(timeout=5.0) assert not thread.is_alive() From 000341a85d51a35fe2f9d078e73dfcd3ce31f748 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Wed, 3 Dec 2025 12:50:10 -0500 Subject: [PATCH 22/28] the issue is dameon rep socket is now blocking, so we wait --- src/cortex/discovery/daemon.py | 29 +++++++++++++++-------------- tests/conftest.py | 3 +-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index a494fa0..713f586 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -10,7 +10,6 @@ import contextlib import os -import threading import zmq @@ -57,9 +56,8 @@ def __init__( self._context: zmq.Context | None = None self._socket: zmq.Socket | None = None - # Control flags + # Control flag self._running = False - self._shutdown_event = threading.Event() def _ensure_ipc_path(self) -> None: """Ensure the IPC socket directory exists.""" @@ -90,7 +88,6 @@ def start(self) -> None: self._socket.setsockopt(zmq.LINGER, 0) # Immediate shutdown self._running = True - self._shutdown_event.clear() logger.info("=" * 50) logger.info("DISCOVERY DAEMON STARTED") @@ -102,13 +99,13 @@ def start(self) -> None: except KeyboardInterrupt: logger.info("Received interrupt signal") finally: - self.stop() + self._cleanup() def _run_loop(self) -> None: """Main event loop.""" - while self._running and not self._shutdown_event.is_set(): + while self._running: try: - # Try to receive a request + # Try to receive a request (blocks up to RCVTIMEO) try: request_bytes = self._socket.recv(copy=False) @@ -116,10 +113,13 @@ def _run_loop(self) -> None: response = self._handle_request(request_bytes) self._socket.send(response.to_bytes()) except zmq.Again: - # No message available, continue + # Timeout, check _running and continue continue except Exception as e: + if not self._running: + # We're shutting down, exit cleanly + break logger.error(f"Error in discovery loop: {e}") # Send error response if we received a request try: @@ -253,12 +253,8 @@ def _handle_shutdown(self) -> DiscoveryResponse: logger.info("SHUTDOWN command received") return DiscoveryResponse(status=DiscoveryStatus.OK, message="Shutting down") - def stop(self) -> None: - """Stop the discovery daemon.""" - logger.info("Stopping discovery daemon") - self._shutdown_event.set() - self._running = False - + def _cleanup(self) -> None: + """Clean up resources.""" if self._socket: try: self._socket.close() @@ -284,6 +280,11 @@ def stop(self) -> None: logger.info("DISCOVERY DAEMON STOPPED") logger.info("=" * 50) + def stop(self) -> None: + """Stop the discovery daemon.""" + logger.info("Stopping discovery daemon") + self._running = False + def main(): """Entry point for the discovery daemon.""" diff --git a/tests/conftest.py b/tests/conftest.py index 2bf2154..324c733 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -77,8 +77,7 @@ def run_daemon(): def stop(self) -> None: """Stop the discovery daemon.""" if self._daemon: - self._daemon._running = False - self._daemon._shutdown_event.set() + self._daemon.stop() # Wait for thread to finish (daemon cleans up its own context) if self._thread: From a01447402c01024bf6d2054ec325ccf332535428 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Tue, 17 Mar 2026 09:57:37 -0400 Subject: [PATCH 23/28] improved latency, throughput and added some docs for help --- benchmarks/bench_all.py | 89 +++++--- docs/design-review.md | 155 ++++++++++++++ docs/optimizations.md | 106 ++++++++++ src/cortex/core/publisher.py | 11 +- src/cortex/core/subscriber.py | 19 +- src/cortex/messages/base.py | 96 ++++++--- src/cortex/utils/serialization.py | 330 +++++++++++++++++++----------- tests/test_messages.py | 17 ++ tests/test_serialization.py | 20 ++ 9 files changed, 661 insertions(+), 182 deletions(-) create mode 100644 docs/design-review.md create mode 100644 docs/optimizations.md diff --git a/benchmarks/bench_all.py b/benchmarks/bench_all.py index fe38b27..dbeb563 100644 --- a/benchmarks/bench_all.py +++ b/benchmarks/bench_all.py @@ -15,11 +15,13 @@ from pathlib import Path import numpy as np +import zmq sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from bench_latency import run_latency_benchmark from bench_throughput import run_throughput_benchmark +from cortex.messages.standard import ArrayMessage def run_all_benchmarks() -> dict: @@ -186,8 +188,7 @@ def get_system_info() -> dict: def measure_serialization_overhead() -> dict: - """Measure serialization/deserialization overhead.""" - from cortex.utils.serialization import deserialize, serialize + """Measure wire serialization overhead using multipart transport frames.""" results = {} @@ -199,41 +200,74 @@ def measure_serialization_overhead() -> dict: ] for name, data in test_cases: + message = ArrayMessage(data=data) + topic = b"/benchmark/serialization" + endpoint = f"inproc://cortex_serialization_{name}" + context = zmq.Context.instance() + sender = context.socket(zmq.PAIR) + receiver = context.socket(zmq.PAIR) + sender.setsockopt(zmq.LINGER, 0) + receiver.setsockopt(zmq.LINGER, 0) + receiver.bind(endpoint) + sender.connect(endpoint) + + def frame_size_bytes(frames: list[object]) -> int: + total = 0 + for frame in frames: + if hasattr(frame, "nbytes"): + total += int(frame.nbytes) + else: + total += len(frame) + return total + # Warm up for _ in range(10): - serialized = serialize(data) - deserialize(serialized) + sender.send_multipart([topic, *message.to_frames()], copy=False) + warmup_frames = receiver.recv_multipart(copy=False) + ArrayMessage.from_frames(warmup_frames[1:]) - # Benchmark serialization + # Benchmark A->B wire transfer and B-side decode iterations = 100 + wire_total = 0.0 + decode_total = 0.0 + frames = [] - start = time.perf_counter() for _ in range(iterations): - serialized = serialize(data) - serialize_time = (time.perf_counter() - start) / iterations * 1000 # ms + wire_start = time.perf_counter() + sender.send_multipart([topic, *message.to_frames()], copy=False) + frames = receiver.recv_multipart(copy=False) + wire_end = time.perf_counter() - # Benchmark deserialization - start = time.perf_counter() - for _ in range(iterations): - deserialize(serialized) - deserialize_time = (time.perf_counter() - start) / iterations * 1000 # ms + decode_start = wire_end + ArrayMessage.from_frames(frames[1:]) + decode_end = time.perf_counter() + + wire_total += wire_end - wire_start + decode_total += decode_end - decode_start + + serialize_time = (wire_total / iterations) * 1000 # ms (A->B wire path) + deserialize_time = (decode_total / iterations) * 1000 # ms (B-side decode) - data_size_kb = data.nbytes / 1024 + # Use real wire bytes including topic frame. + data_size_bytes = frame_size_bytes(frames) + data_size_kb = data_size_bytes / 1024 results[name] = { "data_size_kb": data_size_kb, + "wire_size_bytes": data_size_bytes, "serialize_ms": serialize_time, "deserialize_ms": deserialize_time, "total_ms": serialize_time + deserialize_time, - "serialize_throughput_mb_s": (data_size_kb / 1024) - / (serialize_time / 1000), - "deserialize_throughput_mb_s": (data_size_kb / 1024) - / (deserialize_time / 1000), + # Throughput is intentionally omitted here because inproc multipart + # transport with copy=False can look unrealistically high and is + # often misread as physical link bandwidth. + "roundtrip_ms": serialize_time + deserialize_time, } - print( - f" - {name}: serialize={serialize_time:.3f}ms, deserialize={deserialize_time:.3f}ms" - ) + print(f" - {name}: to_wire={serialize_time:.3f}ms, from_wire={deserialize_time:.3f}ms") + + sender.close() + receiver.close() return results @@ -288,17 +322,20 @@ def print_summary(results: dict) -> None: f"{data['loss_rate_percent']:>10.2f}" ) - # Serialization overhead - print("\n📊 SERIALIZATION OVERHEAD") + # Wire serialization overhead + print("\n📊 WIRE SERIALIZATION OVERHEAD (MULTIPART)") print("-" * 60) - print(f"{'Size':<20} {'Serialize':>12} {'Deserialize':>12} {'Throughput':>12}") + print( + f"{'Size':<20} {'Wire Bytes':>12} {'To Wire':>12} {'From Wire':>12} {'Roundtrip':>12}" + ) print("-" * 60) for name, data in results["benchmarks"].get("serialization", {}).items(): print( - f"{name:<20} {data['serialize_ms']:>10.3f}ms " + f"{name:<20} {data['wire_size_bytes']:>12,} " + f"{data['serialize_ms']:>10.3f}ms " f"{data['deserialize_ms']:>10.3f}ms " - f"{data['serialize_throughput_mb_s']:>10.1f} MB/s" + f"{data['roundtrip_ms']:>10.3f}ms" ) print("\n" + "=" * 80) diff --git a/docs/design-review.md b/docs/design-review.md new file mode 100644 index 0000000..a978577 --- /dev/null +++ b/docs/design-review.md @@ -0,0 +1,155 @@ +# Cortex Design Review + +This is a candid review of the current Cortex architecture as it exists today. + +## What The Design Gets Right + +### 1. The mental model is clean + +`Node`, `Publisher`, `Subscriber`, and `Executor` are easy to understand. That matters in robotics because most teams are already debugging hardware, timing, and perception stacks. They do not need a messaging library that is conceptually expensive. + +### 2. ZeroMQ over IPC is a good local-first choice + +For single-machine robotics workloads, IPC sockets are a practical baseline: + +- deployment is simple, +- dependencies are light, +- latency is decent, +- failure modes are understandable. + +This is substantially more pragmatic than trying to start with a distributed RPC stack. + +### 3. Message typing is lightweight but useful + +The fingerprinting approach gives you a useful form of type identity without requiring code generation. For a Python-first system, that is a reasonable tradeoff. + +### 4. The design is honest about being Pythonic + +This library is not pretending to be a hard-real-time transport. That is good. A lot of robotics middleware gets worse when it makes timing promises the language runtime cannot actually keep. + +## What The Design Gets Wrong + +### 1. The discovery daemon is a single chokepoint + +The discovery plane is centralized, synchronous, and stateful. That is acceptable for a toy system or a small workstation setup. It is not a strong foundation for a larger graph. + +Problems: + +- one daemon is a single point of failure, +- REQ/REP forces lockstep request handling, +- startup storms can serialize on a single socket, +- there is no notion of leases, heartbeats, or stale publisher reclamation beyond best-effort unregister. + +This will become painful the moment processes crash uncleanly, restart frequently, or run across multiple machines. + +### 2. Topic ownership is effectively one-publisher-per-topic + +That is a serious limitation. Many robotics systems eventually need: + +- redundant publishers, +- replicated sensors, +- failover, +- multiple producers feeding a shared stream. + +The current registry rejects competing publishers by topic. That is simple, but it is also a structural dead end for anything beyond a tightly controlled single-producer graph. + +### 3. The transport is local-only in practice + +The architecture and defaults are built around IPC paths under `/tmp`. That is fine for a workstation. It does not scale to: + +- multi-host robots, +- edge-to-base-station links, +- containerized deployments with namespace boundaries, +- cloud or cluster processing. + +The code says “distributed systems” in spirit, but the design is really “single-host process graph with discovery.” Those are not the same thing. + +### 4. Backpressure policy is underspecified + +Queue size exists, but the system does not expose a strong contract about what happens under overload. + +Important unanswered questions: + +- Are old messages dropped or new ones? +- Should publishers block or fail fast? +- Should subscribers conflate or queue? +- What is the intended behavior for high-rate sensors versus low-rate control topics? + +Without explicit backpressure semantics, the system behaves however ZeroMQ happens to behave under current socket settings. That is not design. That is delegation. + +### 5. Callback execution is too naive for serious pipelines + +Subscribers process callbacks in a simple async loop. That works for demos. It breaks down when callbacks become non-trivial. + +Problems: + +- one slow callback can stall message handling, +- there is no built-in bounded work queue, +- there is no separation between receive, decode, and user callback execution, +- there is no scheduler policy per topic. + +As graphs grow, this turns into timing jitter and head-of-line blocking. + +### 6. Message compatibility is brittle + +Fingerprinting based on Python field structure is convenient, but it is not a robust schema evolution story. + +You do not currently have a first-class answer for: + +- optional fields added over time, +- backwards-compatible evolution, +- deprecation windows, +- cross-language interoperability, +- stable wire contracts. + +This is acceptable for internal experiments. It is weak for long-lived systems. + +## Scaling Problems You Will Hit + +### 1. Process count scaling + +As node count rises, discovery traffic and socket management get noisier. Even if steady-state data bypasses discovery, startup and restart behavior will degrade first. + +### 2. Topic count scaling + +Per-topic socket endpoints become operationally messy at larger scale. Many topics means many IPC files, many bindings, and more cleanup edge cases after crashes. + +### 3. Payload size scaling + +Large images, point clouds, and model tensors will keep stressing Python object creation, buffer ownership, and callback scheduling even after the current serialization improvements. Shared memory is not required yet, but eventually this design will force you toward it. + +### 4. Multi-machine scaling + +The current design will need more than minor edits to become a robust networked middleware. You will need to rethink: + +- discovery, +- transport security, +- reconnection semantics, +- publisher liveness, +- addressability, +- observability. + +### 5. Reliability scaling + +There is little explicit modeling of delivery guarantees, replay, durability, or health monitoring. For robotics this is survivable in best-effort telemetry. It is much less acceptable for supervisory control, autonomy arbitration, or safety-critical status propagation. + +## Brutal Summary + +Cortex is a good small-system local IPC library. + +It is not yet a mature robotics middleware. + +Its strengths are simplicity, low ceremony, and a sensible local-first transport choice. Its weaknesses are exactly the ones that appear when a clean prototype meets scale: centralized discovery, simplistic scheduling, underspecified overload behavior, weak schema evolution, and single-host assumptions baked into the architecture. + +If the goal is a fast Python-native pub/sub layer for one machine or a tightly managed robot process graph, the design is good enough and now materially faster on large payloads. + +If the goal is something that competes with established robotics middleware as systems become larger, more distributed, and more failure-prone, the current design will run into hard walls rather than soft inefficiencies. + +## Concrete Architectural Next Steps + +1. Define transport semantics per topic class: telemetry, control, state, bulk sensor, debug. +2. Introduce explicit backpressure and drop policies instead of relying on implicit socket behavior. +3. Add publisher liveness or lease-based discovery so stale registrations disappear automatically. +4. Decouple receive, decode, and callback execution with bounded queues. +5. Decide whether Cortex is intentionally single-host or genuinely multi-host, then design for that decision instead of straddling both. +6. Add a real schema evolution strategy before message types spread across projects. \ No newline at end of file diff --git a/docs/optimizations.md b/docs/optimizations.md new file mode 100644 index 0000000..f841522 --- /dev/null +++ b/docs/optimizations.md @@ -0,0 +1,106 @@ +# Cortex Optimization Notes + +This document covers the optimizations implemented in March 2026 to reduce serialization and transport overhead in Cortex without introducing shared memory. + +## Goals + +- Reduce Python-side copying for large NumPy payloads. +- Reduce per-message metadata overhead. +- Preserve the public `Message`, `Publisher`, and `Subscriber` APIs. +- Keep the discovery plane unchanged. + +## Implemented Changes + +### 1. Frame-aware transport for large payloads + +Before this change, a message was serialized into a single Python `bytes` blob and sent as one ZMQ frame. For array-heavy robotics traffic, that meant: + +- building a large Python bytes object in user space, +- copying array contents into that bytes object, +- then handing the merged blob to ZeroMQ for another copy into its own buffers. + +The transport path now uses message frames: + +- frame 1: fixed-size message header, +- frame 2: packed metadata for field values, +- frame 3+: raw array or tensor buffers. + +This matters because top-level and nested arrays no longer need to be flattened into the metadata blob. The sender can hand ZeroMQ a view of the contiguous array buffer directly. + +### 2. Schema-ordered message payloads + +`Message.to_bytes()` previously serialized message fields as a dictionary keyed by field name. That repeated field names on every message and forced more structure building on both encode and decode. + +Messages now serialize field values in dataclass declaration order. The receiver reconstructs the message using the cached field order for that message class. + +Benefits: + +- less metadata per message, +- fewer Python objects created on the hot path, +- no repeated field-name encoding on every publish. + +### 3. Cached message schema metadata + +Message classes now cache their ordered field names. This is a small optimization by itself, but it matters because the send path is otherwise rebuilding dataclass metadata every time. + +### 4. Msgpack extension types for inline array/tensor support + +The generic serialization helpers no longer recursively wrap every nested value with custom type headers. Instead, they lean on msgpack directly and use extension hooks for: + +- NumPy arrays, +- PyTorch tensors. + +That removes a large amount of Python recursion and custom length-prefix bookkeeping for nested dict/list payloads. + +### 5. Zero-copy NumPy decode by default + +`deserialize_numpy()` used to end with `arr.copy()`, which guaranteed an extra full-memory copy on every decode. + +That copy is now removed by default. The function returns a NumPy array backed by the source buffer unless the caller explicitly asks for `copy=True`. + +This is a meaningful win for image, lidar, and tensor-like workloads where the extra copy was pure overhead. + +### 6. Subscriber receive path now uses non-copying ZMQ frame access + +The subscriber now receives multipart data with `copy=False` and reconstructs arrays from the underlying frame buffers. This lines up with the out-of-band transport and prevents unnecessary Python-side copies before NumPy even sees the data. + +## Expected Impact + +The main expected wins are: + +- lower CPU time for large array messages, +- lower allocator pressure, +- lower tail latency under sustained throughput, +- better bandwidth utilization for image and point-cloud payloads. + +The biggest gains should show up on: + +- `ArrayMessage`, +- `ImageMessage`, +- point cloud style messages with multiple arrays, +- nested dictionary payloads that include arrays. + +The smallest gains will be on tiny primitive-only messages, where ZeroMQ scheduling and Python coroutine overhead dominate. + +## Tradeoffs Introduced + +- Some decoded NumPy arrays now reference the incoming transport buffer instead of owning their own copy. That is faster, but it means downstream code should copy only when it truly needs ownership or mutability guarantees. +- The frame transport path is more complex than the original single-blob path. That complexity is justified for robotics-sized payloads, but it does raise maintenance cost. + +## What Was Deliberately Not Done + +- No shared memory transport. +- No compression. +- No alternate wire format such as FlatBuffers, Cap'n Proto, or Protobuf. +- No change to the discovery architecture. +- No batching layer above PUB/SUB. + +## Recommended Next Steps + +If more performance is needed, the next high-value steps are: + +1. Add benchmark coverage that measures `to_bytes()` versus `to_frames()` by payload type and size. +2. Split control-plane and data-plane tuning explicitly, including socket options such as HWM, `IMMEDIATE`, and publish/drop policy. +3. Introduce explicit ownership semantics for received arrays so users can choose `borrowed` versus `owned` decode behavior. +4. Revisit the message format for multi-array messages to avoid repeated per-array metadata when schema is fixed. +5. Only after those steps, consider shared memory for very large colocated pipelines. \ No newline at end of file diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index 7e588d2..e421a67 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -84,6 +84,7 @@ def __init__( # Generate IPC address for this topic self.address = generate_ipc_address(topic_name, node_name) + self._topic_bytes = topic_name.encode("utf-8") # ZMQ setup - context provided by Node # if context is async context, convert to sync context @@ -172,12 +173,12 @@ def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: ) try: - # Serialize and send - data = message.to_bytes() - - # Send with topic name as first frame for filtering + # Send with topic name as first frame for filtering. + # Message payload uses frame-aware transport to keep large buffers + # out of the metadata blob. self._socket.send_multipart( - [self.topic_name.encode("utf-8"), data], flags=flags + [self._topic_bytes, *message.to_frames()], + flags=flags, ) self._publish_count += 1 diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index 501ebb7..05edaca 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -201,16 +201,23 @@ async def receive(self) -> tuple[Message, MessageHeader] | None: return None try: - # Receive multipart message [topic, data] - frames = await self._socket.recv_multipart() + # Receive multipart message [topic, header, metadata, *buffers] + frames = await self._socket.recv_multipart(copy=False) - if len(frames) != 2: + if len(frames) < 2: logger.warning(f"Unexpected frame count: {len(frames)}") return None - # Parse the message - _topic_bytes, data = frames - message, header = self.message_type.from_bytes(data) + payload_frames = frames[1:] + if len(payload_frames) == 1: + raw_payload = ( + memoryview(payload_frames[0].buffer) + if hasattr(payload_frames[0], "buffer") + else payload_frames[0] + ) + message, header = self.message_type.from_bytes(raw_payload) + else: + message, header = self.message_type.from_frames(payload_frames) self._receive_count += 1 self._last_receive_time = time.time() diff --git a/src/cortex/messages/base.py b/src/cortex/messages/base.py index f04a01c..faa9b3f 100644 --- a/src/cortex/messages/base.py +++ b/src/cortex/messages/base.py @@ -1,9 +1,4 @@ -""" -Base message classes for Cortex. - -All message types should inherit from Message and use the @dataclass decorator. -The message system provides automatic serialization and 64-bit fingerprinting. -""" +"""Base message classes for Cortex.""" import struct import time @@ -13,11 +8,23 @@ from cortex.utils.hashing import ( get_cached_fingerprint, ) -from cortex.utils.serialization import deserialize_message_data, serialize_message_data +from cortex.utils.serialization import ( + deserialize_message_frames, + deserialize_message_values, + serialize_message_frames, + serialize_message_values, +) T = TypeVar("T", bound="Message") +def _frame_to_bytes_like(frame: object) -> bytes | memoryview: + """Convert transport frame objects into bytes-like buffers.""" + if hasattr(frame, "buffer"): + return memoryview(frame.buffer) + return frame + + class MessageType: """ Registry for message types by fingerprint. @@ -100,6 +107,7 @@ class PointCloud(Message): # Class-level sequence counter _sequence_counter: ClassVar[int] = 0 + _field_names_cache: ClassVar[tuple[str, ...] | None] = None def __init_subclass__(cls, **kwargs): """Automatically register subclasses.""" @@ -120,6 +128,37 @@ def _next_sequence(cls) -> int: cls._sequence_counter += 1 return seq + @classmethod + def _field_names(cls) -> tuple[str, ...]: + """Get cached dataclass field names in declaration order.""" + cached = cls.__dict__.get("_field_names_cache") + if cached is None: + cached = tuple(field.name for field in fields(cls)) + cls._field_names_cache = cached + return cached + + def _field_values(self) -> list[object]: + """Get field values in schema order.""" + return [getattr(self, name) for name in self._field_names()] + + @classmethod + def _build_instance(cls: type[T], values: list[object]) -> T: + """Create a message instance from ordered field values.""" + field_names = cls._field_names() + if len(values) != len(field_names): + raise ValueError( + f"Expected {len(field_names)} fields for {cls.__name__}, got {len(values)}" + ) + return cls(**dict(zip(field_names, values, strict=True))) + + def _build_header(self) -> MessageHeader: + """Create a message header for the current instance.""" + return MessageHeader( + fingerprint=self.fingerprint(), + timestamp_ns=time.time_ns(), + sequence=self._next_sequence(), + ) + def to_bytes(self) -> bytes: """ Serialize the message to bytes. @@ -128,23 +167,17 @@ def to_bytes(self) -> bytes: - 24 bytes: header (fingerprint, timestamp, sequence) - remaining: serialized field data """ - # Build header - header = MessageHeader( - fingerprint=self.fingerprint(), - timestamp_ns=time.time_ns(), - sequence=self._next_sequence(), - ) - - # Get field data (excluding inherited fields from dataclass machinery) - field_data = {} - for f in fields(self): - field_data[f.name] = getattr(self, f.name) + header_bytes = self._build_header().to_bytes() + data_bytes = serialize_message_values(self._field_values()) + return header_bytes + data_bytes - # Serialize - header_bytes = header.to_bytes() - data_bytes = serialize_message_data(field_data) + def to_frames(self) -> list[object]: + """Serialize the message into transport frames. - return header_bytes + data_bytes + The first frame is always the fixed-size header. The second frame holds + packed metadata, and any remaining frames are raw out-of-band buffers. + """ + return [self._build_header().to_bytes(), *serialize_message_frames(self._field_values())] @classmethod def from_bytes(cls: type[T], data: bytes) -> tuple[T, MessageHeader]: @@ -154,16 +187,19 @@ def from_bytes(cls: type[T], data: bytes) -> tuple[T, MessageHeader]: Returns: Tuple of (message instance, header) """ - # Parse header header = MessageHeader.from_bytes(data) + values = deserialize_message_values(data[MessageHeader.size() :]) + return cls._build_instance(values), header - # Parse field data - field_data = deserialize_message_data(data[MessageHeader.size() :]) - - # Create instance - instance = cls(**field_data) - - return instance, header + @classmethod + def from_frames(cls: type[T], frames: list[object]) -> tuple[T, MessageHeader]: + """Deserialize a message from transport frames.""" + if len(frames) < 2: + raise ValueError("Message frame payload must include header and metadata") + + header = MessageHeader.from_bytes(_frame_to_bytes_like(frames[0])) + values = deserialize_message_frames(frames[1:]) + return cls._build_instance(values), header @staticmethod def decode(data: bytes) -> tuple["Message", MessageHeader]: diff --git a/src/cortex/utils/serialization.py b/src/cortex/utils/serialization.py index 6f33fd9..fa5fc53 100644 --- a/src/cortex/utils/serialization.py +++ b/src/cortex/utils/serialization.py @@ -1,12 +1,4 @@ -""" -Serialization utilities for Cortex messages. - -Supports efficient serialization of: -- NumPy arrays -- PyTorch tensors (optional) -- Python dictionaries -- Primitive types -""" +"""Serialization utilities for Cortex messages.""" import struct from enum import IntEnum @@ -37,6 +29,149 @@ class DataType(IntEnum): BYTES = 6 +_NUMPY_EXT_CODE = 1 +_TORCH_EXT_CODE = 2 +_OOB_MARKER = "__cortex_oob__" + + +def _as_buffer_view(data: Any) -> memoryview: + """Return a memoryview for bytes-like objects and ZMQ frames.""" + if hasattr(data, "buffer"): + return memoryview(data.buffer) + return memoryview(data) + + +def _encode_numpy_payload(arr: np.ndarray) -> bytes: + """Encode a NumPy array payload for msgpack ext transport.""" + contiguous = np.ascontiguousarray(arr) + payload = ( + contiguous.dtype.str, + contiguous.shape, + contiguous.tobytes(order="C"), + ) + return msgpack.packb(payload, use_bin_type=True) + + +def _decode_numpy_payload(payload: bytes) -> np.ndarray: + """Decode a NumPy array payload from msgpack ext transport.""" + dtype_str, shape, raw = msgpack.unpackb(payload, raw=False) + array = np.frombuffer(raw, dtype=np.dtype(dtype_str)).reshape(tuple(shape)) + return array + + +def _msgpack_default(value: Any) -> msgpack.ExtType: + """Msgpack default hook with array/tensor support.""" + if isinstance(value, np.ndarray): + return msgpack.ExtType(_NUMPY_EXT_CODE, _encode_numpy_payload(value)) + + if TORCH_AVAILABLE and isinstance(value, torch.Tensor): + device_str = str(value.device) + requires_grad = value.requires_grad + contiguous = np.ascontiguousarray(value.detach().cpu().numpy()) + payload = msgpack.packb( + ( + device_str, + requires_grad, + contiguous.dtype.str, + contiguous.shape, + contiguous.tobytes(order="C"), + ), + use_bin_type=True, + ) + return msgpack.ExtType(_TORCH_EXT_CODE, payload) + + raise TypeError(f"Unsupported type for serialization: {type(value)!r}") + + +def _msgpack_ext_hook(code: int, payload: bytes) -> Any: + """Msgpack ext hook with array/tensor support.""" + if code == _NUMPY_EXT_CODE: + return _decode_numpy_payload(payload) + + if code == _TORCH_EXT_CODE: + if not TORCH_AVAILABLE: + raise RuntimeError("PyTorch is not available") + + device_str, requires_grad, dtype_str, shape, raw = msgpack.unpackb( + payload, raw=False + ) + array = np.frombuffer(raw, dtype=np.dtype(dtype_str)).reshape(tuple(shape)) + tensor = torch.from_numpy(array.copy()) + if device_str.startswith("cuda") and torch.cuda.is_available(): + tensor = tensor.to(device_str) + if requires_grad: + tensor.requires_grad_(True) + return tensor + + return msgpack.ExtType(code, payload) + + +def _encode_transport_value(value: Any, buffers: list[Any]) -> Any: + """Replace large buffers with out-of-band descriptors for transport.""" + if isinstance(value, np.ndarray): + contiguous = np.ascontiguousarray(value) + buffer_index = len(buffers) + buffers.append(contiguous) + return { + _OOB_MARKER: "numpy", + "buffer": buffer_index, + "dtype": contiguous.dtype.str, + "shape": list(contiguous.shape), + } + + if TORCH_AVAILABLE and isinstance(value, torch.Tensor): + contiguous = np.ascontiguousarray(value.detach().cpu().numpy()) + buffer_index = len(buffers) + buffers.append(contiguous) + return { + _OOB_MARKER: "torch", + "buffer": buffer_index, + "dtype": contiguous.dtype.str, + "shape": list(contiguous.shape), + "device": str(value.device), + "requires_grad": value.requires_grad, + } + + if isinstance(value, dict): + return {key: _encode_transport_value(item, buffers) for key, item in value.items()} + + if isinstance(value, (list, tuple)): + return [_encode_transport_value(item, buffers) for item in value] + + return value + + +def _decode_transport_value(value: Any, buffers: list[Any]) -> Any: + """Restore out-of-band transport descriptors back into Python values.""" + if isinstance(value, dict) and _OOB_MARKER in value: + buffer_index = value["buffer"] + buffer_view = _as_buffer_view(buffers[buffer_index]) + shape = tuple(value["shape"]) + array = np.frombuffer(buffer_view, dtype=np.dtype(value["dtype"])).reshape(shape) + + if value[_OOB_MARKER] == "numpy": + return array + + if value[_OOB_MARKER] == "torch": + if not TORCH_AVAILABLE: + raise RuntimeError("PyTorch is not available") + tensor = torch.from_numpy(array.copy()) + device_str = value["device"] + if device_str.startswith("cuda") and torch.cuda.is_available(): + tensor = tensor.to(device_str) + if value["requires_grad"]: + tensor.requires_grad_(True) + return tensor + + if isinstance(value, dict): + return {key: _decode_transport_value(item, buffers) for key, item in value.items()} + + if isinstance(value, list): + return [_decode_transport_value(item, buffers) for item in value] + + return value + + def serialize_numpy(arr: np.ndarray) -> bytes: """ Serialize a NumPy array to bytes. @@ -47,21 +182,22 @@ def serialize_numpy(arr: np.ndarray) -> bytes: - variable: dtype string length (2 bytes) + dtype string - remaining: raw array data """ - ndim = arr.ndim - shape = arr.shape - dtype_str = str(arr.dtype).encode("utf-8") - - # Pack header: ndim (1 byte) + shape (4 bytes each) + dtype - header = struct.pack(f">B{ndim}I", ndim, *shape) - header += struct.pack(">H", len(dtype_str)) + dtype_str - - # Get contiguous array data - data = np.ascontiguousarray(arr).tobytes() + contiguous = np.ascontiguousarray(arr) + ndim = contiguous.ndim + dtype_str = contiguous.dtype.str.encode("utf-8") + + header_size = 1 + (4 * ndim) + 2 + len(dtype_str) + header = bytearray(header_size) + struct.pack_into(f">B{ndim}I", header, 0, ndim, *contiguous.shape) + offset = 1 + (4 * ndim) + struct.pack_into(">H", header, offset, len(dtype_str)) + offset += 2 + header[offset:] = dtype_str - return header + data + return bytes(header) + contiguous.tobytes(order="C") -def deserialize_numpy(data: bytes) -> tuple[np.ndarray, int]: +def deserialize_numpy(data: bytes | memoryview, *, copy: bool = False) -> tuple[np.ndarray, int]: """ Deserialize bytes to a NumPy array. @@ -69,32 +205,35 @@ def deserialize_numpy(data: bytes) -> tuple[np.ndarray, int]: Tuple of (array, bytes_consumed) """ offset = 0 + view = _as_buffer_view(data) # Read ndim - ndim = struct.unpack(">B", data[offset : offset + 1])[0] + ndim = struct.unpack(">B", view[offset : offset + 1])[0] offset += 1 # Read shape - shape = struct.unpack(f">{ndim}I", data[offset : offset + 4 * ndim]) + shape = struct.unpack(f">{ndim}I", view[offset : offset + 4 * ndim]) offset += 4 * ndim # Read dtype - dtype_len = struct.unpack(">H", data[offset : offset + 2])[0] + dtype_len = struct.unpack(">H", view[offset : offset + 2])[0] offset += 2 - dtype_str = data[offset : offset + dtype_len].decode("utf-8") + dtype_str = bytes(view[offset : offset + dtype_len]).decode("utf-8") offset += dtype_len # Calculate data size and read dtype = np.dtype(dtype_str) size = int(np.prod(shape)) * dtype.itemsize - arr_data = data[offset : offset + size] + arr_data = view[offset : offset + size] offset += size arr = np.frombuffer(arr_data, dtype=dtype).reshape(shape) - return arr.copy(), offset + if copy: + arr = arr.copy() + return arr, offset -def serialize_torch(tensor: "torch.Tensor") -> bytes: +def serialize_torch(tensor: Any) -> bytes: """ Serialize a PyTorch tensor to bytes. @@ -119,7 +258,7 @@ def serialize_torch(tensor: "torch.Tensor") -> bytes: return meta + arr_bytes -def deserialize_torch(data: bytes) -> tuple["torch.Tensor", int]: +def deserialize_torch(data: bytes) -> tuple[Any, int]: """ Deserialize bytes to a PyTorch tensor. @@ -140,7 +279,7 @@ def deserialize_torch(data: bytes) -> tuple["torch.Tensor", int]: offset += device_len # Deserialize numpy array - arr, arr_bytes = deserialize_numpy(data[offset:]) + arr, arr_bytes = deserialize_numpy(data[offset:], copy=True) offset += arr_bytes # Convert to tensor @@ -171,38 +310,24 @@ def serialize(value: Any) -> bytes: return struct.pack(">B", DataType.NONE) if isinstance(value, np.ndarray): - type_byte = struct.pack(">B", DataType.NUMPY) - return type_byte + serialize_numpy(value) + return struct.pack(">B", DataType.NUMPY) + serialize_numpy(value) if TORCH_AVAILABLE and isinstance(value, torch.Tensor): - type_byte = struct.pack(">B", DataType.TORCH) - return type_byte + serialize_torch(value) + return struct.pack(">B", DataType.TORCH) + serialize_torch(value) if isinstance(value, bytes): - type_byte = struct.pack(">B", DataType.BYTES) - length = struct.pack(">I", len(value)) - return type_byte + length + value + return struct.pack(">BI", DataType.BYTES, len(value)) + value if isinstance(value, dict): - type_byte = struct.pack(">B", DataType.DICT) - # Recursively serialize dict values - serialized_dict = {} - for k, v in value.items(): - serialized_dict[k] = serialize(v) - packed = msgpack.packb(serialized_dict, use_bin_type=True) - return type_byte + struct.pack(">I", len(packed)) + packed + packed = msgpack.packb(value, default=_msgpack_default, use_bin_type=True) + return struct.pack(">BI", DataType.DICT, len(packed)) + packed if isinstance(value, (list, tuple)): - type_byte = struct.pack(">B", DataType.LIST) - # Recursively serialize list items - serialized_list = [serialize(item) for item in value] - packed = msgpack.packb(serialized_list, use_bin_type=True) - return type_byte + struct.pack(">I", len(packed)) + packed - - # Primitive types: use msgpack - type_byte = struct.pack(">B", DataType.PRIMITIVE) + packed = msgpack.packb(value, default=_msgpack_default, use_bin_type=True) + return struct.pack(">BI", DataType.LIST, len(packed)) + packed + packed = msgpack.packb(value, use_bin_type=True) - return type_byte + struct.pack(">I", len(packed)) + packed + return struct.pack(">BI", DataType.PRIMITIVE, len(packed)) + packed def deserialize(data: bytes) -> tuple[Any, int]: @@ -213,56 +338,32 @@ def deserialize(data: bytes) -> tuple[Any, int]: Tuple of (value, bytes_consumed) """ offset = 0 - data_type = DataType(struct.unpack(">B", data[offset : offset + 1])[0]) + view = _as_buffer_view(data) + data_type = DataType(struct.unpack(">B", view[offset : offset + 1])[0]) offset += 1 if data_type == DataType.NONE: return None, offset if data_type == DataType.NUMPY: - arr, arr_bytes = deserialize_numpy(data[offset:]) + arr, arr_bytes = deserialize_numpy(view[offset:]) return arr, offset + arr_bytes if data_type == DataType.TORCH: - tensor, tensor_bytes = deserialize_torch(data[offset:]) + tensor, tensor_bytes = deserialize_torch(bytes(view[offset:])) return tensor, offset + tensor_bytes if data_type == DataType.BYTES: - length = struct.unpack(">I", data[offset : offset + 4])[0] + length = struct.unpack(">I", view[offset : offset + 4])[0] offset += 4 - return data[offset : offset + length], offset + length - - if data_type == DataType.DICT: - length = struct.unpack(">I", data[offset : offset + 4])[0] - offset += 4 - packed = data[offset : offset + length] - offset += length - - serialized_dict = msgpack.unpackb(packed, raw=False) - result = {} - for k, v in serialized_dict.items(): - result[k], _ = deserialize(v) - return result, offset + return bytes(view[offset : offset + length]), offset + length - if data_type == DataType.LIST: - length = struct.unpack(">I", data[offset : offset + 4])[0] + if data_type in (DataType.DICT, DataType.LIST, DataType.PRIMITIVE): + length = struct.unpack(">I", view[offset : offset + 4])[0] offset += 4 - packed = data[offset : offset + length] - offset += length - - serialized_list = msgpack.unpackb(packed, raw=False) - result = [] - for item in serialized_list: - val, _ = deserialize(item) - result.append(val) - return result, offset - - if data_type == DataType.PRIMITIVE: - length = struct.unpack(">I", data[offset : offset + 4])[0] - offset += 4 - packed = data[offset : offset + length] - offset += length - return msgpack.unpackb(packed, raw=False), offset + payload = view[offset : offset + length] + value = msgpack.unpackb(payload, raw=False, ext_hook=_msgpack_ext_hook) + return value, offset + length raise ValueError(f"Unknown data type: {data_type}") @@ -279,39 +380,38 @@ def serialize_message_data(fields: dict[str, Any]) -> bytes: - 4 bytes: value length - value bytes """ - parts = [struct.pack(">H", len(fields))] + return serialize(fields) - for key, value in fields.items(): - key_bytes = key.encode("utf-8") - value_bytes = serialize(value) - parts.append(struct.pack(">H", len(key_bytes))) - parts.append(key_bytes) - parts.append(struct.pack(">I", len(value_bytes))) - parts.append(value_bytes) +def deserialize_message_data(data: bytes) -> dict[str, Any]: + """Deserialize bytes to message fields.""" + fields, _ = deserialize(data) + return fields - return b"".join(parts) +def serialize_message_values(values: list[Any] | tuple[Any, ...]) -> bytes: + """Serialize ordered message field values.""" + return serialize(list(values)) -def deserialize_message_data(data: bytes) -> dict[str, Any]: - """Deserialize bytes to message fields.""" - offset = 0 - num_fields = struct.unpack(">H", data[offset : offset + 2])[0] - offset += 2 - fields = {} - for _ in range(num_fields): - key_len = struct.unpack(">H", data[offset : offset + 2])[0] - offset += 2 - key = data[offset : offset + key_len].decode("utf-8") - offset += key_len +def deserialize_message_values(data: bytes | memoryview) -> list[Any]: + """Deserialize ordered message field values.""" + values, _ = deserialize(_as_buffer_view(data)) + return values - value_len = struct.unpack(">I", data[offset : offset + 4])[0] - offset += 4 - value_bytes = data[offset : offset + value_len] - offset += value_len - value, _ = deserialize(value_bytes) - fields[key] = value +def serialize_message_frames(values: list[Any] | tuple[Any, ...]) -> list[Any]: + """Serialize message values into metadata plus out-of-band buffer frames.""" + buffers: list[Any] = [] + encoded_values = [_encode_transport_value(value, buffers) for value in values] + metadata = msgpack.packb(encoded_values, use_bin_type=True) + return [metadata, *buffers] - return fields + +def deserialize_message_frames(frames: list[Any]) -> list[Any]: + """Deserialize metadata plus out-of-band buffer frames into message values.""" + if not frames: + return [] + + encoded_values = msgpack.unpackb(_as_buffer_view(frames[0]), raw=False) + return [_decode_transport_value(value, frames[1:]) for value in encoded_values] diff --git a/tests/test_messages.py b/tests/test_messages.py index bcc08fb..6afb7a6 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -80,6 +80,23 @@ class RoundtripMsg(Message): assert restored.name == original.name assert header.fingerprint == RoundtripMsg.fingerprint() + def test_message_frame_roundtrip(self): + """Messages should roundtrip through the frame transport path.""" + + @dataclass + class FrameMsg(Message): + value: int + data: np.ndarray + + original = FrameMsg(value=7, data=np.arange(12, dtype=np.float32).reshape(3, 4)) + frames = original.to_frames() + + restored, header = FrameMsg.from_frames(frames) + + assert restored.value == original.value + np.testing.assert_array_equal(restored.data, original.data) + assert header.fingerprint == FrameMsg.fingerprint() + def test_message_decode(self): """Messages should decode without knowing type.""" diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 6f3346f..7fc88c1 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -76,6 +76,26 @@ def test_various_dtypes(self): np.testing.assert_array_equal(arr, restored) assert arr.dtype == restored.dtype, f"dtype mismatch for {dtype}" + def test_deserialize_numpy_zero_copy(self): + """NumPy deserialization should avoid an extra copy by default.""" + arr = np.arange(16, dtype=np.float32).reshape(4, 4) + + data = serialize_numpy(arr) + restored, _ = deserialize_numpy(data) + + assert restored.base is not None + np.testing.assert_array_equal(arr, restored) + + def test_deserialize_numpy_copy_opt_in(self): + """Callers can request an owned NumPy buffer when needed.""" + arr = np.arange(8, dtype=np.int32) + + data = serialize_numpy(arr) + restored, _ = deserialize_numpy(data, copy=True) + + assert restored.base is None + np.testing.assert_array_equal(arr, restored) + class TestGenericSerialization: """Tests for generic serialize/deserialize functions.""" From 36865973716681966ec0d4746427543c8b543dd3 Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Tue, 17 Mar 2026 09:59:12 -0400 Subject: [PATCH 24/28] ruff chore --- benchmarks/bench_all.py | 5 ++++- src/cortex/messages/base.py | 5 ++++- src/cortex/utils/serialization.py | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/benchmarks/bench_all.py b/benchmarks/bench_all.py index dbeb563..5487cd9 100644 --- a/benchmarks/bench_all.py +++ b/benchmarks/bench_all.py @@ -21,6 +21,7 @@ from bench_latency import run_latency_benchmark from bench_throughput import run_throughput_benchmark + from cortex.messages.standard import ArrayMessage @@ -264,7 +265,9 @@ def frame_size_bytes(frames: list[object]) -> int: "roundtrip_ms": serialize_time + deserialize_time, } - print(f" - {name}: to_wire={serialize_time:.3f}ms, from_wire={deserialize_time:.3f}ms") + print( + f" - {name}: to_wire={serialize_time:.3f}ms, from_wire={deserialize_time:.3f}ms" + ) sender.close() receiver.close() diff --git a/src/cortex/messages/base.py b/src/cortex/messages/base.py index faa9b3f..b9fcb10 100644 --- a/src/cortex/messages/base.py +++ b/src/cortex/messages/base.py @@ -177,7 +177,10 @@ def to_frames(self) -> list[object]: The first frame is always the fixed-size header. The second frame holds packed metadata, and any remaining frames are raw out-of-band buffers. """ - return [self._build_header().to_bytes(), *serialize_message_frames(self._field_values())] + return [ + self._build_header().to_bytes(), + *serialize_message_frames(self._field_values()), + ] @classmethod def from_bytes(cls: type[T], data: bytes) -> tuple[T, MessageHeader]: diff --git a/src/cortex/utils/serialization.py b/src/cortex/utils/serialization.py index fa5fc53..cccc01a 100644 --- a/src/cortex/utils/serialization.py +++ b/src/cortex/utils/serialization.py @@ -133,7 +133,9 @@ def _encode_transport_value(value: Any, buffers: list[Any]) -> Any: } if isinstance(value, dict): - return {key: _encode_transport_value(item, buffers) for key, item in value.items()} + return { + key: _encode_transport_value(item, buffers) for key, item in value.items() + } if isinstance(value, (list, tuple)): return [_encode_transport_value(item, buffers) for item in value] @@ -147,7 +149,9 @@ def _decode_transport_value(value: Any, buffers: list[Any]) -> Any: buffer_index = value["buffer"] buffer_view = _as_buffer_view(buffers[buffer_index]) shape = tuple(value["shape"]) - array = np.frombuffer(buffer_view, dtype=np.dtype(value["dtype"])).reshape(shape) + array = np.frombuffer(buffer_view, dtype=np.dtype(value["dtype"])).reshape( + shape + ) if value[_OOB_MARKER] == "numpy": return array @@ -164,7 +168,9 @@ def _decode_transport_value(value: Any, buffers: list[Any]) -> Any: return tensor if isinstance(value, dict): - return {key: _decode_transport_value(item, buffers) for key, item in value.items()} + return { + key: _decode_transport_value(item, buffers) for key, item in value.items() + } if isinstance(value, list): return [_decode_transport_value(item, buffers) for item in value] @@ -197,7 +203,9 @@ def serialize_numpy(arr: np.ndarray) -> bytes: return bytes(header) + contiguous.tobytes(order="C") -def deserialize_numpy(data: bytes | memoryview, *, copy: bool = False) -> tuple[np.ndarray, int]: +def deserialize_numpy( + data: bytes | memoryview, *, copy: bool = False +) -> tuple[np.ndarray, int]: """ Deserialize bytes to a NumPy array. From 1fd549fe63d71a58fde8d195fb7b1cf8ad2cccdc Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 18 Apr 2026 09:20:29 -0400 Subject: [PATCH 25/28] update docstrings and prep codebase for mkdocs --- README.md | 3 -- pyproject.toml | 10 ++++++- src/cortex/core/executor.py | 21 ++++++++------ src/cortex/core/node.py | 33 +++++++++++---------- src/cortex/core/publisher.py | 52 ++++++++++++++++++++++++---------- src/cortex/core/subscriber.py | 18 ++++++++---- src/cortex/discovery/client.py | 22 ++++++++++---- src/cortex/discovery/daemon.py | 11 +++++-- src/cortex/messages/base.py | 29 ++++++++++++------- src/cortex/utils/loop.py | 17 ++++++----- 10 files changed, 144 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index b6b21c6..a6bb394 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,6 @@ The discovery daemon must be running for publishers and subscribers to find each ```bash # Start the discovery daemon -python -m cortex.discovery.daemon - -# Or use the installed command cortex-discovery ``` diff --git a/pyproject.toml b/pyproject.toml index 1bc3382..2cb9345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,8 +40,16 @@ dev = [ "pytest-timeout>=2.0.0", "ruff>=0.8.0", ] +docs = [ + "zensical", + "mkdocstrings[python]>=0.26", + "mkdocs-gen-files>=0.5", + "mkdocs-literate-nav>=0.6", + "mkdocs-section-index>=0.3", + "ruff>=0.8.0", +] all = [ - "cortex[torch,dev]", + "cortex[torch,dev,docs]", ] [project.scripts] diff --git a/src/cortex/core/executor.py b/src/cortex/core/executor.py index 6fbdf1b..668d272 100644 --- a/src/cortex/core/executor.py +++ b/src/cortex/core/executor.py @@ -61,19 +61,21 @@ async def _run_impl(self, *args, **kwargs) -> None: class AsyncExecutor(BaseExecutor): - """ - Executor that runs an async function as fast as possible. + """Runs an async callable in a tight loop, yielding to the event loop. - Yields to the event loop between executions to allow other - coroutines to run. + Used by :class:`cortex.core.subscriber.Subscriber` to drive its + receive → decode → dispatch loop. Exceptions are logged and the loop + continues; only :class:`asyncio.CancelledError` stops it. Example: + ```python async def process_data(): data = await get_data() await handle(data) executor = AsyncExecutor(process_data) await executor.run() + ``` """ async def _run_impl(self, *args, **kwargs) -> None: @@ -90,18 +92,21 @@ async def _run_impl(self, *args, **kwargs) -> None: class RateExecutor(BaseExecutor): - """ - Executor that runs an async function at a constant rate. + """Runs an async callable at a target rate in Hz. - Provides precise timing for periodic execution of async callbacks. - Uses cooperative multitasking - ideal for I/O-bound workloads. + Uses ``time.perf_counter`` for scheduling and catches up on overruns by + advancing ``next_exec_time`` instead of firing back-to-back. Dropped + ticks are **not** reported — suitable for telemetry and periodic I/O, + but not for hard real-time control without external monitoring. Example: + ```python async def my_callback(): print("tick") executor = RateExecutor(my_callback, rate_hz=10.0) await executor.run() + ``` """ def __init__(self, func: AsyncCallback, rate_hz: float): diff --git a/src/cortex/core/node.py b/src/cortex/core/node.py index b385122..3bef724 100644 --- a/src/cortex/core/node.py +++ b/src/cortex/core/node.py @@ -22,30 +22,33 @@ class Node: - """ - A node in the Cortex communication graph. + """User-facing composition unit that owns publishers, subscribers, and timers. + + A node bundles a shared :class:`zmq.asyncio.Context`, a collection of + :class:`cortex.core.publisher.Publisher` and + :class:`cortex.core.subscriber.Subscriber` instances created through it, + and any number of periodic timer callbacks. - Nodes manage a collection of publishers and subscribers using asyncio - for cooperative multitasking. + :meth:`run` starts every subscriber receive loop and every timer as + asyncio tasks and ``gather``s them until cancelled. Use as an async + context manager so that :meth:`close` runs on exit and cleans up + sockets, tasks, and the shared ZMQ context. Example: + ```python class CameraNode(Node): def __init__(self): super().__init__("camera_node") - - self.pub = self.create_publisher( - "/camera/image", - ImageMessage - ) - - self.create_timer(1/30, self.publish_image) + self.pub = self.create_publisher("/camera/image", ImageMessage) + self.create_timer(1 / 30, self.publish_image) async def publish_image(self): - image = capture_image() - self.pub.publish(ImageMessage(data=image)) + self.pub.publish(ImageMessage(data=capture_image())) - async def run(self): - await super().run() + async def main(): + async with CameraNode() as node: + await node.run() + ``` """ def __init__( diff --git a/src/cortex/core/publisher.py b/src/cortex/core/publisher.py index e421a67..8a2123d 100644 --- a/src/cortex/core/publisher.py +++ b/src/cortex/core/publisher.py @@ -22,11 +22,19 @@ def generate_ipc_address(topic_name: str, node_name: str) -> str: - """ - Generate a IPC address for a topic and node. + """Build the deterministic IPC endpoint for a ``(node, topic)`` pair. + + The path lives under ``/tmp/cortex/topics/`` and encodes the node name + and topic so that the same pair always produces the same socket file. - Assumption is that node_name and topic_name form a unique combination. - So there are no two publishers with the same node_name publishing the same topic_name. + Args: + topic_name: Topic path, e.g. ``/camera/image``. Leading slashes are + converted to underscores in the filename. + node_name: Owning node's name. Must be unique per topic within the + host — duplicate pairs would race on the socket file. + + Returns: + A ``ipc://...`` URI suitable for :func:`zmq.Socket.bind`. """ # Create a safe filename from topic name and node name safe_name = node_name + "__" + topic_name.replace("/", "_").lstrip("_") @@ -39,19 +47,29 @@ def generate_ipc_address(topic_name: str, node_name: str) -> str: class Publisher: - """ - Publisher for sending messages on a topic. + """Sends typed messages on a topic over a ZMQ PUB socket. - Uses ZeroMQ PUB socket over IPC for efficient local communication. - Automatically registers with the discovery daemon. + On construction the publisher binds its own IPC socket, registers the + ``(topic, address, fingerprint)`` triple with the discovery daemon, and + becomes ready. :meth:`publish` is synchronous and non-blocking by default + — if the send queue is full the message is dropped and ``False`` is + returned. - Note: Always create publishers through Node.create_publisher(). + Always create via :meth:`Node.create_publisher`; that path shares the + node's async context and tracks the publisher for clean shutdown. + + Note: + ``zmq.PUB`` sockets are **not thread-safe**. Do not call + :meth:`publish` concurrently from multiple threads or tasks on the + same :class:`Publisher` instance. Example: + ```python async with Node("camera_node") as node: pub = node.create_publisher("/camera/image", ImageMessage) pub.publish(ImageMessage(data=image_array)) await node.run() + ``` """ def __init__( @@ -154,18 +172,22 @@ def _register_with_discovery(self) -> None: logger.warning(f"Could not connect to discovery daemon: {e}") def publish(self, message: Message, flags: int = zmq.NOBLOCK) -> bool: - """ - Publish a message (non-blocking). + """Serialize and send ``message`` on this topic. + + Uses the frame-aware transport path so large NumPy / PyTorch buffers + ride as separate ZMQ frames (zero-copy handoff). Args: - message: The message to publish (must match message_type) - flags: ZMQ flags for sending (default: NOBLOCK) + message: Instance whose class matches :attr:`message_type`. + flags: ZMQ send flags. Default :data:`zmq.NOBLOCK` — drop on + high-water-mark rather than block the caller. Returns: - True if the message was sent successfully + ``True`` if ZMQ accepted the message; ``False`` if the queue was + full (``zmq.Again``) or another send error was logged. Raises: - TypeError: If message type doesn't match + TypeError: If ``type(message)`` does not match :attr:`message_type`. """ if not isinstance(message, self.message_type): raise TypeError( diff --git a/src/cortex/core/subscriber.py b/src/cortex/core/subscriber.py index 05edaca..a50fae6 100644 --- a/src/cortex/core/subscriber.py +++ b/src/cortex/core/subscriber.py @@ -25,21 +25,29 @@ class Subscriber: - """ - Subscriber for receiving messages on a topic. + """Receives typed messages on a topic from a ZMQ SUB socket. + + On construction, the subscriber performs a non-blocking lookup against + the discovery daemon. If the topic already has a publisher it connects + immediately; otherwise it defers and retries with a polling wait inside + :meth:`run`. - Uses ZeroMQ SUB socket over IPC for efficient local communication. - Automatically discovers the topic using the discovery daemon. + When constructed with a ``callback`` the subscriber drives its own + receive loop (one task, one callback at a time — see + :class:`cortex.core.executor.AsyncExecutor`). Without a callback the + subscriber is passive and the caller polls via :meth:`receive`. - Note: Always create subscribers through Node.create_subscriber(). + Always create via :meth:`Node.create_subscriber`. Example: + ```python async def callback(msg, header): print(f"Received: {msg}") async with Node("my_node") as node: node.create_subscriber("/topic", MyMsg, callback) await node.run() + ``` """ def __init__( diff --git a/src/cortex/discovery/client.py b/src/cortex/discovery/client.py index 4c76378..e07fcdd 100644 --- a/src/cortex/discovery/client.py +++ b/src/cortex/discovery/client.py @@ -25,11 +25,23 @@ class DiscoveryClient: - """ - Client for interacting with the discovery daemon. - - Provides methods for registering, unregistering, and looking up topics. - Uses synchronous ZMQ since discovery is typically done at startup. + """Synchronous REQ client for the Cortex discovery daemon. + + Built around a single ``zmq.REQ`` socket. Because REQ sockets get stuck + in a bad state after a missed reply (they block further sends), this + client transparently closes and recreates the socket on every timeout + via :meth:`_reconnect`. + + Used internally by :class:`cortex.core.publisher.Publisher` at registration + time and by :class:`cortex.core.subscriber.Subscriber` for topic lookup. + User code rarely needs to instantiate this class directly. + + Example: + ```python + client = DiscoveryClient() + info = client.wait_for_topic("/camera/image", timeout=5.0) + print(info.address if info else "not found") + ``` """ def __init__( diff --git a/src/cortex/discovery/daemon.py b/src/cortex/discovery/daemon.py index 713f586..c1a9aca 100644 --- a/src/cortex/discovery/daemon.py +++ b/src/cortex/discovery/daemon.py @@ -31,10 +31,15 @@ class DiscoveryDaemon: - """ - Discovery daemon that maintains topic registry. + """Long-lived REP service that maps topic names to ZMQ endpoints. + + Publishers register their topic on startup; subscribers look up the + endpoint and then connect directly. The daemon is **not** on the data + path — it sees control traffic only. - Uses ZMQ REP socket to handle requests from publishers and subscribers. + Single-threaded by design. Requests are handled one at a time with a + 1-second ``RCVTIMEO`` so the loop can observe ``_running`` for clean + shutdown. """ def __init__( diff --git a/src/cortex/messages/base.py b/src/cortex/messages/base.py index b9fcb10..91d8dfa 100644 --- a/src/cortex/messages/base.py +++ b/src/cortex/messages/base.py @@ -26,11 +26,16 @@ def _frame_to_bytes_like(frame: object) -> bytes | memoryview: class MessageType: - """ - Registry for message types by fingerprint. + """Registry mapping 64-bit fingerprints to ``Message`` subclasses. + + Populated automatically via :meth:`Message.__init_subclass__`. Used by + :meth:`Message.decode` to dispatch an incoming byte stream to the right + concrete class based on the fingerprint in its header. - This allows automatic deserialization based on the 64-bit fingerprint - sent with each message. + Example: + >>> from cortex.messages.standard import ArrayMessage + >>> MessageType.get(ArrayMessage.fingerprint()) is ArrayMessage + True """ _registry: ClassVar[dict[int, type["Message"]]] = {} @@ -60,15 +65,19 @@ def clear(cls) -> None: @dataclass class MessageHeader: - """ - Header for all Cortex messages. + """Fixed-size 24-byte header prepended to every Cortex message. + + Layout (big-endian): ``fingerprint u64 | timestamp_ns u64 | sequence u64``. - Contains metadata that is sent with every message. + Attributes: + fingerprint: 64-bit type identifier. See :func:`cortex.utils.hashing.compute_fingerprint`. + timestamp_ns: Wall-clock nanoseconds at publish time (``time.time_ns()``). + sequence: Per-process, per-message-type monotonic counter. """ - fingerprint: int # 64-bit type identifier - timestamp_ns: int # Nanosecond timestamp - sequence: int # Sequence number + fingerprint: int + timestamp_ns: int + sequence: int def to_bytes(self) -> bytes: """Serialize header to bytes (24 bytes fixed size).""" diff --git a/src/cortex/utils/loop.py b/src/cortex/utils/loop.py index 30ab86a..0170c32 100644 --- a/src/cortex/utils/loop.py +++ b/src/cortex/utils/loop.py @@ -8,6 +8,8 @@ import importlib.util import logging import sys +from collections.abc import Coroutine +from typing import Any logger = logging.getLogger("cortex.loop") @@ -17,18 +19,19 @@ ) -def run(coro, *, debug: bool = False): - """ - Run a coroutine using uvloop if available. +def run(coro: Coroutine[Any, Any, Any], *, debug: bool = False) -> Any: + """Run a coroutine, preferring ``uvloop`` when available. - This is a replacement for asyncio.run() that uses uvloop on Unix. + Drop-in replacement for :func:`asyncio.run`. On Unix with ``uvloop`` + installed, this yields noticeably lower tail latency on high-rate + small-message workloads. Args: - coro: Coroutine to run - debug: Enable debug mode + coro: The top-level coroutine to run to completion. + debug: Pass through to the event loop's ``debug`` flag. Returns: - The result of the coroutine + Whatever ``coro`` returns. """ if _uvloop_available: import uvloop From 986aea13ed423ee3d9091406052dcb5504cdddea Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 18 Apr 2026 09:21:01 -0400 Subject: [PATCH 26/28] docs added --- docs/components/discovery.md | 128 + docs/components/messages.md | 110 + docs/components/node-and-executors.md | 206 ++ docs/components/publisher-subscriber.md | 280 ++ docs/components/serialization.md | 133 + docs/concepts/architecture.md | 96 + docs/concepts/async-execution-model.md | 88 + docs/concepts/discovery-protocol.md | 111 + docs/concepts/fingerprinting.md | 104 + docs/concepts/message-wire-format.md | 96 + docs/concepts/transport-and-qos.md | 33 + docs/critique.md | 146 + docs/design-review.md | 155 -- docs/gen_ref_pages.py | 47 + docs/getting-started/discovery-daemon.md | 69 + docs/getting-started/installation.md | 42 + docs/getting-started/quickstart.md | 104 + docs/guides/benchmarks.md | 35 + docs/guides/debugging.md | 58 + docs/guides/performance-tuning.md | 37 + docs/index.md | 83 + docs/mkdocs.yml | 142 + docs/optimizations.md | 106 - docs/site/404.html | 1599 +++++++++++ docs/site/assets/images/favicon.png | Bin 0 -> 1870 bytes docs/site/assets/javascripts/LICENSE | 29 + .../assets/javascripts/bundle.63456bd9.min.js | 3 + .../workers/search.e2d2d235.min.js | 1 + .../stylesheets/classic/main.96fc3bb8.min.css | 1 + .../classic/palette.7dc9a0ad.min.css | 1 + .../stylesheets/modern/main.53a7feaf.min.css | 1 + .../modern/palette.dfe2e883.min.css | 1 + docs/site/components/discovery/index.html | 2124 ++++++++++++++ docs/site/components/messages/index.html | 2013 ++++++++++++++ .../components/node-and-executors/index.html | 2166 +++++++++++++++ .../publisher-subscriber/index.html | 2428 +++++++++++++++++ docs/site/components/serialization/index.html | 2077 ++++++++++++++ docs/site/concepts/architecture/index.html | 1902 +++++++++++++ .../concepts/async-execution-model/index.html | 1937 +++++++++++++ .../concepts/discovery-protocol/index.html | 2027 ++++++++++++++ docs/site/concepts/fingerprinting/index.html | 1950 +++++++++++++ .../concepts/message-wire-format/index.html | 1965 +++++++++++++ .../concepts/transport-and-qos/index.html | 1867 +++++++++++++ docs/site/critique/index.html | 2223 +++++++++++++++ docs/site/gen_ref_pages.py | 47 + .../discovery-daemon/index.html | 1896 +++++++++++++ .../getting-started/installation/index.html | 1855 +++++++++++++ .../getting-started/quickstart/index.html | 1906 +++++++++++++ docs/site/guides/benchmarks/index.html | 1831 +++++++++++++ docs/site/guides/debugging/index.html | 1905 +++++++++++++ .../site/guides/performance-tuning/index.html | 1869 +++++++++++++ docs/site/index.html | 1855 +++++++++++++ docs/site/mkdocs.yml | 142 + docs/site/objects.inv | Bin 0 -> 127 bytes docs/site/search.json | 1 + docs/site/sitemap.xml | 72 + .../site/tutorials/custom-messages/index.html | 2039 ++++++++++++++ .../tutorials/multi-node-system/index.html | 2077 ++++++++++++++ .../tutorials/numpy-and-images/index.html | 1946 +++++++++++++ .../site/tutorials/pytorch-tensors/index.html | 1986 ++++++++++++++ docs/tutorials/custom-messages.md | 140 + docs/tutorials/multi-node-system.md | 179 ++ docs/tutorials/numpy-and-images.md | 97 + docs/tutorials/pytorch-tensors.md | 110 + 64 files changed, 50416 insertions(+), 261 deletions(-) create mode 100644 docs/components/discovery.md create mode 100644 docs/components/messages.md create mode 100644 docs/components/node-and-executors.md create mode 100644 docs/components/publisher-subscriber.md create mode 100644 docs/components/serialization.md create mode 100644 docs/concepts/architecture.md create mode 100644 docs/concepts/async-execution-model.md create mode 100644 docs/concepts/discovery-protocol.md create mode 100644 docs/concepts/fingerprinting.md create mode 100644 docs/concepts/message-wire-format.md create mode 100644 docs/concepts/transport-and-qos.md create mode 100644 docs/critique.md delete mode 100644 docs/design-review.md create mode 100644 docs/gen_ref_pages.py create mode 100644 docs/getting-started/discovery-daemon.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/getting-started/quickstart.md create mode 100644 docs/guides/benchmarks.md create mode 100644 docs/guides/debugging.md create mode 100644 docs/guides/performance-tuning.md create mode 100644 docs/index.md create mode 100644 docs/mkdocs.yml delete mode 100644 docs/optimizations.md create mode 100644 docs/site/404.html create mode 100644 docs/site/assets/images/favicon.png create mode 100644 docs/site/assets/javascripts/LICENSE create mode 100644 docs/site/assets/javascripts/bundle.63456bd9.min.js create mode 100644 docs/site/assets/javascripts/workers/search.e2d2d235.min.js create mode 100644 docs/site/assets/stylesheets/classic/main.96fc3bb8.min.css create mode 100644 docs/site/assets/stylesheets/classic/palette.7dc9a0ad.min.css create mode 100644 docs/site/assets/stylesheets/modern/main.53a7feaf.min.css create mode 100644 docs/site/assets/stylesheets/modern/palette.dfe2e883.min.css create mode 100644 docs/site/components/discovery/index.html create mode 100644 docs/site/components/messages/index.html create mode 100644 docs/site/components/node-and-executors/index.html create mode 100644 docs/site/components/publisher-subscriber/index.html create mode 100644 docs/site/components/serialization/index.html create mode 100644 docs/site/concepts/architecture/index.html create mode 100644 docs/site/concepts/async-execution-model/index.html create mode 100644 docs/site/concepts/discovery-protocol/index.html create mode 100644 docs/site/concepts/fingerprinting/index.html create mode 100644 docs/site/concepts/message-wire-format/index.html create mode 100644 docs/site/concepts/transport-and-qos/index.html create mode 100644 docs/site/critique/index.html create mode 100644 docs/site/gen_ref_pages.py create mode 100644 docs/site/getting-started/discovery-daemon/index.html create mode 100644 docs/site/getting-started/installation/index.html create mode 100644 docs/site/getting-started/quickstart/index.html create mode 100644 docs/site/guides/benchmarks/index.html create mode 100644 docs/site/guides/debugging/index.html create mode 100644 docs/site/guides/performance-tuning/index.html create mode 100644 docs/site/index.html create mode 100644 docs/site/mkdocs.yml create mode 100644 docs/site/objects.inv create mode 100644 docs/site/search.json create mode 100644 docs/site/sitemap.xml create mode 100644 docs/site/tutorials/custom-messages/index.html create mode 100644 docs/site/tutorials/multi-node-system/index.html create mode 100644 docs/site/tutorials/numpy-and-images/index.html create mode 100644 docs/site/tutorials/pytorch-tensors/index.html create mode 100644 docs/tutorials/custom-messages.md create mode 100644 docs/tutorials/multi-node-system.md create mode 100644 docs/tutorials/numpy-and-images.md create mode 100644 docs/tutorials/pytorch-tensors.md diff --git a/docs/components/discovery.md b/docs/components/discovery.md new file mode 100644 index 0000000..607d2b6 --- /dev/null +++ b/docs/components/discovery.md @@ -0,0 +1,128 @@ +# Discovery + +> **Source:** [`cortex.discovery.daemon`](../reference/discovery/daemon.md), +> [`cortex.discovery.client`](../reference/discovery/client.md), +> [`cortex.discovery.protocol`](../reference/discovery/protocol.md) + +Discovery is Cortex's control plane: a single long-lived process that maps +topic names to ZMQ endpoints. It sits off the data path — once a subscriber +has an endpoint, messages flow publisher → subscriber directly without the +daemon's involvement. + +## Moving parts + +```mermaid +flowchart LR + subgraph DP[discovery package] + PR[protocol.py
DiscoveryRequest /
DiscoveryResponse /
TopicInfo] + DM[daemon.py
DiscoveryDaemon
ZMQ REP loop] + CL[client.py
DiscoveryClient
ZMQ REQ wrapper] + end + + CL -- msgpack REQ --> DM + DM -- msgpack REP --> CL + PR -.-> DM + PR -.-> CL +``` + +Everyone agrees on the wire format via `protocol.py`. The daemon runs a +single-threaded REP loop. The client speaks REQ from every publisher and +subscriber in the graph. + +## Daemon + +Implemented in [`DiscoveryDaemon`][cortex.discovery.daemon.DiscoveryDaemon]. + +Key behaviors: + +- Binds `zmq.REP` at `ipc:///tmp/cortex/discovery.sock` by default. +- Maintains `_topics: dict[str, TopicInfo]` — **one publisher per topic**. +- `RCVTIMEO=1000` on the socket so the loop can check `_running` for clean + Ctrl-C. This also means the daemon is naturally single-request-at-a-time — + a slow client blocks all others. + +### State transitions + +```mermaid +stateDiagram-v2 + [*] --> Starting + Starting --> Running: bind OK + Running --> Running: REGISTER → insert + Running --> Running: LOOKUP → read + Running --> Running: UNREGISTER → delete + Running --> Running: LIST → snapshot + Running --> Stopping: SIGINT / SHUTDOWN + Stopping --> [*]: close socket, unlink .sock +``` + +### Registry semantics + +| Case | Result | +| -------------------------------------- | ------------------ | +| New topic | Insert → OK | +| Same topic, same `publisher_node` | Overwrite → OK (re-registration) | +| Same topic, different `publisher_node` | Reject → ALREADY_EXISTS | +| UNREGISTER missing topic | NOT_FOUND | + +## Client + +Implemented in [`DiscoveryClient`][cortex.discovery.client.DiscoveryClient]. + +Thin REQ wrapper around the protocol. Important operational detail: **REQ +sockets stick after a timeout** — they block subsequent sends waiting for a +reply that never came. The client handles this by closing and recreating the +socket on every timeout (`_reconnect`). Callers don't see it. + +### REQ timeout recovery + +```mermaid +flowchart TD + S[send request] --> W[wait RCVTIMEO] + W -->|reply| OK[return DiscoveryResponse] + W -->|timeout| T[zmq.Again] + T --> C[close REQ socket] + C --> N[create fresh REQ
same endpoint] + N -->|attempts < retries| S + N -->|exhausted| F[raise TimeoutError] +``` + +### Polling helpers + +- [`lookup_topic(name)`][cortex.discovery.client.DiscoveryClient.lookup_topic] — + one-shot, returns `None` on miss. +- [`wait_for_topic(name, timeout, poll_interval)`][cortex.discovery.client.DiscoveryClient.wait_for_topic] — + blocking poll loop (time.sleep). +- [`wait_for_topic_async(name, timeout, poll_interval)`][cortex.discovery.client.DiscoveryClient.wait_for_topic_async] — + async poll loop (asyncio.sleep). This is what [`Subscriber`][cortex.core.subscriber.Subscriber] + uses when `wait_for_topic=True`. + +## Protocol + +Implemented in [`cortex.discovery.protocol`](../reference/discovery/protocol.md). + +| Type | Purpose | +| -------------------------------------------------------------------- | ----------------------------------------- | +| [`DiscoveryCommand`][cortex.discovery.protocol.DiscoveryCommand] | `REGISTER_TOPIC` / `UNREGISTER_TOPIC` / `LOOKUP_TOPIC` / `LIST_TOPICS` / `SHUTDOWN` | +| [`DiscoveryStatus`][cortex.discovery.protocol.DiscoveryStatus] | `OK` / `NOT_FOUND` / `ALREADY_EXISTS` / `ERROR` | +| [`TopicInfo`][cortex.discovery.protocol.TopicInfo] | name, address, message_type, fingerprint, publisher_node | +| [`DiscoveryRequest`][cortex.discovery.protocol.DiscoveryRequest] | command + optional topic_info / topic_name | +| [`DiscoveryResponse`][cortex.discovery.protocol.DiscoveryResponse] | status, message, topic_info, topics | + +All payloads are msgpack. `TopicInfo` is nested as a packed sub-blob so +discovery responses stay flat. + +## Known limitations + +Summarized here, detailed in [critique.md](../critique.md): + +- One-publisher-per-topic. +- No heartbeats or leases — crashed publishers leave stale entries. +- Single-threaded REP — slow client starves others. +- `retries=1` in the client is a fencepost; effective retries today is zero. +- Daemon state lost on restart; publishers do not auto-re-register. + +## See also + +- [Concepts → Discovery protocol](../concepts/discovery-protocol.md) +- [Getting started → Running the discovery daemon](../getting-started/discovery-daemon.md) +- [Critique](../critique.md) diff --git a/docs/components/messages.md b/docs/components/messages.md new file mode 100644 index 0000000..619ad96 --- /dev/null +++ b/docs/components/messages.md @@ -0,0 +1,110 @@ +# Messages + +> **Source:** [`cortex.messages.base`](../reference/messages/base.md), +> [`cortex.messages.standard`](../reference/messages/standard.md) + +Messages are just `@dataclass`es that inherit from +[`Message`][cortex.messages.base.Message]. Registering with the type system, +computing a fingerprint, and (de)serialization all happen automatically. + +## Anatomy of a message + +```mermaid +classDiagram + class Message { + +fingerprint() int + +to_bytes() bytes + +to_frames() list + +from_bytes(data) tuple + +from_frames(frames) tuple + +decode(bytes) tuple [static] + -_build_header() + -_field_names() tuple + -_field_values() list + -_next_sequence() int + } + class MessageHeader { + +fingerprint: int + +timestamp_ns: int + +sequence: int + +to_bytes() bytes + +from_bytes(data) MessageHeader + +size() int + } + class MessageType { + +register(cls) + +get(fingerprint) type + +get_all() dict + } + Message ..> MessageHeader : emits + Message ..> MessageType : auto-registers on subclass +``` + +## Defining a custom message + +```python +from dataclasses import dataclass +import numpy as np +from cortex.messages.base import Message + +@dataclass +class JointTrajectory(Message): + timestamp: float + positions: np.ndarray # shape (N,) + velocities: np.ndarray # shape (N,) + frame_id: str = "" +``` + +That is the entire contract. The class is registered into +[`MessageType._registry`][cortex.messages.base.MessageType] by fingerprint at +import time, and gains: + +- `JointTrajectory.fingerprint()` — 64-bit ID. +- `msg.to_frames()` / `JointTrajectory.from_frames(frames)` — the transport path. +- `msg.to_bytes()` / `JointTrajectory.from_bytes(data)` — the legacy blob path. +- `Message.decode(blob)` — class dispatch via fingerprint registry. + +## Sequence numbering + +!!! warning "Class-level counter" + `Message._sequence_counter` is shared across **all publisher instances** of + the same message class in the process. Two `ArrayMessage` publishers + interleave sequence numbers. Per-topic gap detection therefore needs a + per-publisher counter today; see [critique.md § 12](../critique.md). + +## Built-in messages + +| Class | Use for | +| ------------------------------------------------------------------------- | --------------------------------------------- | +| [`StringMessage`][cortex.messages.standard.StringMessage] | Plain strings | +| [`IntMessage`][cortex.messages.standard.IntMessage] / [`FloatMessage`][cortex.messages.standard.FloatMessage] | Single scalars | +| [`BytesMessage`][cortex.messages.standard.BytesMessage] | Opaque binary | +| [`DictMessage`][cortex.messages.standard.DictMessage] | Nested dicts with arrays/tensors | +| [`ListMessage`][cortex.messages.standard.ListMessage] | Mixed-type lists | +| [`ArrayMessage`][cortex.messages.standard.ArrayMessage] | Single NumPy array + name / frame_id | +| [`MultiArrayMessage`][cortex.messages.standard.MultiArrayMessage] | `dict[str, np.ndarray]` (e.g. points+colors) | +| [`TensorMessage`][cortex.messages.standard.TensorMessage] | PyTorch tensor (preserves device/grad) | +| [`MultiTensorMessage`][cortex.messages.standard.MultiTensorMessage] | Named tensor bundle (model I/O) | +| [`ImageMessage`][cortex.messages.standard.ImageMessage] | Image + encoding + width/height | +| [`PointCloudMessage`][cortex.messages.standard.PointCloudMessage] | XYZ + optional RGB / intensity / normals | +| [`PoseMessage`][cortex.messages.standard.PoseMessage] | 6-DoF pose (position + quaternion) | +| [`TransformMessage`][cortex.messages.standard.TransformMessage] | 4×4 homogeneous transform | +| [`TimestampMessage`][cortex.messages.standard.TimestampMessage] / [`HeaderMessage`][cortex.messages.standard.HeaderMessage] | ROS-style stamps | + +## Encode / decode lifecycle + +```mermaid +flowchart LR + A[User builds dataclass] --> B[Publisher.publish] + B --> C[message.to_frames] + C --> D[[ZMQ multipart send]] + D --> E[[ZMQ multipart recv]] + E --> F[Message.from_frames] + F --> G[user callback msg, header] +``` + +## See also + +- [Concept: message wire format](../concepts/message-wire-format.md) +- [Concept: fingerprinting](../concepts/fingerprinting.md) +- [Tutorial: custom messages](../tutorials/custom-messages.md) diff --git a/docs/components/node-and-executors.md b/docs/components/node-and-executors.md new file mode 100644 index 0000000..c022a02 --- /dev/null +++ b/docs/components/node-and-executors.md @@ -0,0 +1,206 @@ +# Node & Executors + +> **Source:** [`cortex.core.node`](../reference/core/node.md), +> [`cortex.core.executor`](../reference/core/executor.md) + +A [`Node`][cortex.core.node.Node] is the user-facing composition unit: it owns +a shared ZMQ async context and a collection of publishers, subscribers, and +timers. Executors provide the scheduling primitives that timers and +subscriber receive loops run on. + +## Responsibilities + +```mermaid +flowchart TB + subgraph NodeResp[Node] + CTX[shared zmq.asyncio.Context] + PUBS[Publishers dict] + SUBS[Subscribers dict] + TIMERS[Timers list] + end + + NodeResp -- create_publisher --> P[Publisher] + NodeResp -- create_subscriber --> S[Subscriber] + NodeResp -- create_timer --> RE[RateExecutor] + NodeResp -- run / close --> Lifecycle + + P -. uses .-> CTX + S -. uses .-> CTX +``` + +One node = one process boundary in practice. Nothing stops you running +multiple nodes in the same process (`asyncio.gather([n.run() for n in nodes])`, +see [`examples/multi_node_system.py`](https://github.com/sudoRicheek/cortex/blob/main/examples/multi_node_system.py)), +but remember they share the same event loop — a slow callback in one still +blocks the others. + +## Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Constructed: Node(name) + Constructed --> Configured: create_publisher/subscriber/timer + Configured --> Running: await node.run() + Running --> Running: timers fire, callbacks dispatch + Running --> Stopping: node.stop() or cancel + Stopping --> Closed: await node.close() + Closed --> [*]: context terminated +``` + +### `node.run()` + +Spawns one asyncio task per timer and one per callback-bearing subscriber, +then `asyncio.gather`s them. Returns when all tasks complete or the node is +stopped. + +```python +async with Node("my_node") as node: + node.create_publisher("/x", IntMessage) + node.create_subscriber("/y", IntMessage, callback=on_y) + await node.run() # blocks until cancelled +# __aexit__ calls close() automatically +``` + +### `node.close()` + +Stops all executors, cancels outstanding tasks, closes every publisher and +subscriber (each of which unregisters/unbinds their own socket), and +terminates the shared ZMQ context. Idempotent. + +## Executors + +Two flavours, both subclasses of `BaseExecutor`. + +```mermaid +classDiagram + class BaseExecutor { + <> + +func: AsyncCallback + +start() + +stop() + +run(*args, **kwargs) + #_run_impl()* + } + class AsyncExecutor { + +_run_impl() + } + class RateExecutor { + +rate_hz: float + +interval: float + +_run_impl() + } + BaseExecutor <|-- AsyncExecutor + BaseExecutor <|-- RateExecutor +``` + +### `AsyncExecutor` + +"Run this coroutine as fast as possible, yielding between iterations." + +```mermaid +flowchart LR + Start --> Check{running?} + Check -- no --> End + Check -- yes --> Call[await func] + Call -- exception --> Log[log error] + Log --> Sleep + Call --> Sleep[await sleep 0] + Sleep --> Check +``` + +Used by `Subscriber.run` to drive the receive-dispatch loop. + +### `RateExecutor` + +"Run this coroutine at a constant rate, catching up on overruns." + +```mermaid +flowchart TD + Start[next = perf_counter] --> Loop{running?} + Loop -- no --> End + Loop -- yes --> Now[now = perf_counter] + Now --> Due{now >= next?} + Due -- yes --> Call[await func] + Call --> Advance[next += interval] + Advance --> Behind{next < now?} + Behind -- yes --> Reset[next = now + interval] + Behind -- no --> Wait + Reset --> Wait + Due -- no --> Wait[await sleep next - now] + Wait --> Loop +``` + +The catch-up branch silently drops ticks — if your 100 Hz callback takes +20 ms once, you do not get two callbacks back-to-back; you skip one tick. + +!!! warning "Redundant yield" + Today there is an `await asyncio.sleep(0)` inside the loop *and* + `await asyncio.sleep(max(0, dt))` at the bottom. That generates an extra + wakeup per tick. See [critique § 15](../critique.md). + +## Timer usage + +```python +node.create_timer(1.0 / 30, self.publish_frame) # 30 Hz +node.create_timer(1.0, self.log_stats) # 1 Hz +``` + +Timers are plain async functions — no decorator, no magic. They run in the +same event loop as subscriber callbacks, so the same head-of-line caveat +applies. + +## Shared ZMQ context + +Every publisher and subscriber created through a node **reuses** the node's +`zmq.asyncio.Context`. This means: + +- Socket creation is cheap. +- io threads are shared across all sockets in the node. +- Terminating the node's context cleanly shuts down all its sockets. + +Do not create your own context inside callbacks; you'll leak resources and +defeat the shared-io-thread optimization. + +## Minimal complete node + +```python +from dataclasses import dataclass +import numpy as np +import cortex +from cortex import Node, Message +from cortex.messages.base import MessageHeader + + +@dataclass +class Ping(Message): + payload: np.ndarray + counter: int + + +class Echo(Node): + def __init__(self): + super().__init__("echo") + self.pub = self.create_publisher("/pong", Ping) + self.create_subscriber("/ping", Ping, callback=self.on_ping) + self._n = 0 + + async def on_ping(self, msg: Ping, header: MessageHeader): + self._n += 1 + self.pub.publish(Ping(payload=msg.payload, counter=self._n)) + + +async def main(): + async with Echo() as node: + await node.run() + + +if __name__ == "__main__": + cortex.run(main()) +``` + +## See also + +- [`cortex.core.node`](../reference/core/node.md) +- [`cortex.core.executor`](../reference/core/executor.md) +- [Concepts → Async execution model](../concepts/async-execution-model.md) +- [Components → Publisher & Subscriber](publisher-subscriber.md) diff --git a/docs/components/publisher-subscriber.md b/docs/components/publisher-subscriber.md new file mode 100644 index 0000000..c6b69f8 --- /dev/null +++ b/docs/components/publisher-subscriber.md @@ -0,0 +1,280 @@ +# Publisher & Subscriber + +> **Source:** [`cortex.core.publisher`](../reference/core/publisher.md), +> [`cortex.core.subscriber`](../reference/core/subscriber.md) + +The data-plane workhorses. A `Publisher` binds a ZMQ `PUB` socket and registers +with discovery; a `Subscriber` looks up the endpoint, connects a `SUB` socket, +and drives an async receive loop. Discovery is consulted **once per topic** on +startup — it is not on the hot path. + +## Relationship to the rest of the stack + +```mermaid +flowchart LR + Node -.owns.-> P[Publisher] + Node -.owns.-> S[Subscriber] + P -- register --> DC1[DiscoveryClient] + S -- lookup --> DC2[DiscoveryClient] + P -- send_multipart --> Sock1[(zmq.PUB
IPC)] + Sock1 -. IPC .-> Sock2[(zmq.SUB)] + S -- recv_multipart --> Sock2 + M[Message] -- to_frames --> P + S -- from_frames --> M +``` + +## Publisher + +### Construction + +Always create via [`Node.create_publisher`][cortex.core.node.Node.create_publisher] — +direct construction works but skips the shared ZMQ context reuse and the +node-level registration bookkeeping. + +```python +pub = node.create_publisher( + topic_name="/camera/image", # must start with "/" + message_type=ImageMessage, # fingerprint is taken from this class + queue_size=100, # SNDHWM; drops under backpressure +) +``` + +### Startup sequence + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant Pub as Publisher + participant FS as /tmp/cortex/topics/ + participant ZMQ as zmq.PUB + participant D as Discovery daemon + + U->>Pub: __init__(topic, msg_cls, ...) + Pub->>Pub: address = generate_ipc_address(topic, node) + Pub->>FS: mkdir -p; unlink stale .sock + Pub->>ZMQ: socket(PUB); setsockopt HWM/LINGER; bind(address) + Pub->>D: REGISTER TopicInfo{name, address, fingerprint, node} + D-->>Pub: OK / ALREADY_EXISTS + Note over Pub: ready; user can publish() +``` + +Two things worth calling out: + +1. The IPC address is derived deterministically from `node_name` and + `topic_name` via [`generate_ipc_address`][cortex.core.publisher.generate_ipc_address]: + `ipc:///tmp/cortex/topics/__.sock`. +2. `_setup_socket` unlinks any existing file at that path before binding. That + protects against crash-leftover sockets, but also means **two publishers + configured with the same `node_name + topic_name` in the same process tree + will silently stomp each other** — see [critique § 10](../critique.md). + +### Publish path + +```mermaid +flowchart LR + Msg[Message dataclass] --> H[build MessageHeader
fp, ts, seq] + Msg --> V[serialize_message_frames
values] + H --> F[[frame 1: header 24B]] + V --> F2[[frame 2: msgpack metadata]] + V --> FN[[frames 3..N: array buffers]] + T[[frame 0: topic bytes]] + F --> Send + F2 --> Send + FN --> Send + T --> Send + Send[send_multipart NOBLOCK] -->|success| Pub[publish count++] + Send -->|zmq.Again| Drop[return False] +``` + +`publish()` is **synchronous** and returns a boolean: + +- `True` — handed to ZMQ successfully. +- `False` — `zmq.Again`, queue full, message dropped. + +Any other exception is logged and swallowed; `publish` still returns `False`. +For robotics code this "fire and forget" is intentional — the caller decides +whether to retry based on the return value and the topic's role. + +### Async context quirk + +`Node` owns a `zmq.asyncio.Context`. The `Publisher` constructor detects this +and wraps a **sync** `zmq.Context` around the same underlying io threads: + +```python +if isinstance(self._context, zmq.asyncio.Context): + self._context: zmq.Context = zmq.Context(self._context) +``` + +This keeps `publish()` a normal function call instead of forcing every publish +to be `await`ed. It is the right performance choice, but it has consequences: + +!!! danger "`zmq.PUB` is not thread-safe" + Do not call `publish()` on the same `Publisher` from multiple threads + (or multiple asyncio tasks that could race on `send_multipart`). Serialize + per-publisher calls yourself if you fan out work. + +### Lifecycle and cleanup + +```mermaid +stateDiagram-v2 + [*] --> Bound: bind + register + Bound --> Publishing: publish() calls + Publishing --> Publishing: more messages + Publishing --> Closed: close() + Bound --> Closed: close() + Closed --> [*]: unregister,
unlink .sock file +``` + +`Publisher.close()` is best-effort: it unregisters from the daemon (silently +tolerates a dead daemon), closes the socket, and removes the IPC file. +Exceptions from any one step do not block the others. + +### Statistics + +`publisher.publish_count`, `publisher.last_publish_time`, and +`publisher.is_registered` are exposed for instrumentation. They update on the +hot path with no locking — read them from the same task that calls `publish()` +for deterministic numbers. + +## Subscriber + +### Construction + +```python +sub = node.create_subscriber( + topic_name="/camera/image", + message_type=ImageMessage, + callback=on_image, # async def callback(msg, header) + queue_size=10, # RCVHWM + wait_for_topic=True, # poll until topic appears + topic_timeout=30.0, # abort wait after N seconds +) +``` + +If `callback` is `None`, the subscriber is passive — call `await sub.receive()` +manually. With a callback, `Node.run()` will drive the receive loop. + +### Startup sequence + +```mermaid +sequenceDiagram + autonumber + participant U as User + participant S as Subscriber + participant D as DiscoveryClient + participant Pub as publisher IPC + + U->>S: __init__(...) + S->>D: lookup_topic(name) # non-blocking + alt found immediately + D-->>S: TopicInfo + S->>S: verify fingerprint + S->>Pub: SUB connect + SUBSCRIBE topic + Note over S: is_connected = True + else not found + D-->>S: None + Note over S: defer; retry in run() + end + + U->>S: node.run() schedules sub.run() + S->>D: wait_for_topic_async(name, timeout) + D-->>S: TopicInfo + S->>Pub: SUB connect + SUBSCRIBE topic +``` + +The constructor tries a non-blocking lookup first so that when a publisher is +already up, no polling is needed. The polling fallback only kicks in inside +`sub.run()` via [`wait_for_topic_async`][cortex.discovery.client.DiscoveryClient.wait_for_topic_async]. + +### Receive loop + +```mermaid +flowchart LR + Loop{{AsyncExecutor}} --> Recv[await recv_multipart copy=False] + Recv --> Frames[frames = topic, header, metadata, *buffers] + Frames --> Decode[Message.from_frames frames 1..] + Decode --> CB[await callback msg, header] + CB --> Yield[await asyncio.sleep 0] + Yield --> Loop +``` + +- `copy=False` means each frame is a `zmq.Frame` — the metadata and array + buffers are memoryview-able without a copy. See + [`cortex.utils.serialization`](../reference/utils/serialization.md). +- The one-frame fast path (`len(payload_frames) == 1`) handles legacy + publishers still on the single-blob path — it falls back to + `from_bytes` on the single payload buffer. + +### Head-of-line blocking + +The callback runs **inline** in the receive loop. A slow callback stalls +everything: + +```mermaid +gantt + title Receive loop when callback is slow + dateFormat X + axisFormat %L ms + section Messages + recv m1 :0, 1 + decode m1 :1, 2 + callback m1 (slow!) :active, 2, 50 + recv m2 (queued on HWM) :crit, 50, 51 + decode m2 :51, 52 + callback m2 :52, 55 +``` + +If callbacks do meaningful work, dispatch them to a task or thread pool: + +```python +import asyncio + +async def on_image(msg, header): + asyncio.create_task(process_in_background(msg, header)) +``` + +Or use a bounded queue + worker pattern. The roadmap item in +[critique § 6](../critique.md) is to lift this into the framework. + +### Fingerprint verification + +On connect the subscriber compares its class's fingerprint to the one in the +registry entry. Today a mismatch only logs a warning and **proceeds anyway** — +downstream decoding will then fail hard. Treat fingerprint warnings as errors +in your code. + +### Cleanup + +`Subscriber.close()` stops the executor, closes the discovery client and SUB +socket, and flips `is_connected` to `False`. Safe to call multiple times; +errors are suppressed so teardown does not cascade. + +## Statistics and instrumentation + +| Property | Publisher | Subscriber | +| ---------------------------------------- | --------- | ---------- | +| `publish_count` / `receive_count` | ✓ | ✓ | +| `last_publish_time` / `last_receive_time`| ✓ | ✓ | +| `is_registered` / `is_connected` | ✓ | ✓ | +| `topic_info` | | ✓ | + +None of these are atomic; treat them as coarse gauges. + +## Common pitfalls + +| Symptom | Cause | Fix | +| ------------------------------------------ | ------------------------------------------------------------------------------------------ | ---------------------------------------- | +| First N messages not received | ZMQ "slow joiner": SUB not connected yet when PUB started publishing | Let subscriber start first, or sleep briefly before first publish | +| Subscriber receives nothing, no errors | Topic name mismatch, or forgot to call `node.run()` | Log both sides; run `cortex-discovery --log-level DEBUG` | +| `publish()` returns `False` repeatedly | Subscriber can't keep up; SNDHWM reached | Increase `queue_size`, or reduce publish rate | +| Mutating a received array "corrupts" later | Decoded arrays alias ZMQ frame memory | `arr = arr.copy()` before mutating | +| Two processes stomp each other's socket | Same `node_name + topic_name` | Unique node names per process | + +## See also + +- [`cortex.core.publisher`](../reference/core/publisher.md) +- [`cortex.core.subscriber`](../reference/core/subscriber.md) +- [Concepts → Async execution model](../concepts/async-execution-model.md) +- [Concepts → Message wire format](../concepts/message-wire-format.md) +- [Guides → Debugging](../guides/debugging.md) diff --git a/docs/components/serialization.md b/docs/components/serialization.md new file mode 100644 index 0000000..485b6b7 --- /dev/null +++ b/docs/components/serialization.md @@ -0,0 +1,133 @@ +# Serialization + +> **Source:** [`cortex.utils.serialization`](../reference/utils/serialization.md), +> [`cortex.utils.hashing`](../reference/utils/hashing.md) + +Two encodings live side by side: a **multipart / out-of-band** path that the +transport actually uses, and a **single-blob** path kept for the legacy +`Message.to_bytes` / `decode` API and tests. Both support the same Python +types; only their frame layout differs. + +## Supported types + +| Type | Inline path (`to_bytes`) | OOB path (`to_frames`) | +| ----------------------------- | ----------------------------- | ----------------------------- | +| `None` | 1 byte tag | msgpack `nil` | +| `int`, `float`, `str`, `bool` | msgpack PRIMITIVE | msgpack | +| `bytes` | tag + length + bytes | msgpack bin | +| `list`, `tuple`, `dict` | msgpack with ExtType arrays | msgpack with OOB descriptors | +| `np.ndarray` | ExtType (inline bytes) | OOB descriptor + extra frame | +| `torch.Tensor` | ExtType (inline bytes) | OOB descriptor + extra frame | + +## The two paths, side by side + +=== "OOB multipart (used on the wire)" + + ```mermaid + flowchart LR + V[values] --> E[_encode_transport_value] + E --> Meta[msgpack metadata
OOB descriptors for arrays] + E --> Bufs[[buffer 0]] + E --> Bufs2[[buffer 1]] + Meta --> Out[(list of frames)] + Bufs --> Out + Bufs2 --> Out + ``` + + The function of interest is + [`serialize_message_frames`][cortex.utils.serialization.serialize_message_frames]: + + ```python + metadata_bytes, [buf0, buf1, ...] = serialize_message_frames(values) + ``` + + Arrays stay contiguous; ZMQ hands the buffer straight to the kernel. + +=== "Inline blob (legacy / `Message.decode`)" + + ```mermaid + flowchart LR + V[values] --> P[msgpack.packb
default=_msgpack_default] + P --> Ext[ExtType 1/2 for arrays/tensors
bytes embedded] + Ext --> Blob[single bytes blob] + ``` + + The single blob round-trips through `serialize(value)` → + `deserialize(data)`. Useful for persisting to disk, caches, or when you + need a self-contained payload without tracking extra buffers. + +## OOB descriptors + +An out-of-band descriptor is a small dict that takes the place of the array +inside the msgpack metadata: + +```python +# numpy +{"__cortex_oob__": "numpy", "buffer": 0, "dtype": ">ZMQ: recv_multipart(copy=False) + ZMQ-->>Sub: frame with .buffer property + Sub->>MV: memoryview(frame.buffer) + Sub->>NP: np.frombuffer(mv, dtype).reshape(shape) + Note over NP: array aliases the ZMQ frame memory +``` + +!!! warning "Aliasing caveat" + The returned NumPy array is **a view over the ZMQ frame buffer**. It is + safe to read as long as the frame lives, which is at least until your + callback returns. If you need to: + + - mutate the array, or + - keep it past the callback, + + call `arr = arr.copy()` first. This is cheap compared to the savings on + the hot path. + +## PyTorch specifics + +- Tensors are **always moved to CPU** for transport. Transport frames carry + the tensor's CPU bytes plus the original device string. +- On decode, CUDA tensors are moved back to the original device when CUDA is + available; otherwise they stay on CPU. +- `requires_grad` is preserved. + +## Fingerprinting + +Separate but related: [`compute_fingerprint(cls)`][cortex.utils.hashing.compute_fingerprint] +computes a 64-bit identity from the module path, class name, and sorted +`field:type` pairs. Cached per-class in `_fingerprint_cache`. See +[Concepts → Fingerprinting](../concepts/fingerprinting.md) for the full story. + +## When to use each helper + +| Helper | Use when | +| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| [`serialize_message_frames`][cortex.utils.serialization.serialize_message_frames] | You're building a custom transport that speaks multipart | +| [`deserialize_message_frames`][cortex.utils.serialization.deserialize_message_frames] | Decoding the above | +| [`serialize(value)`][cortex.utils.serialization.serialize] / [`deserialize`][cortex.utils.serialization.deserialize] | Persisting a single value to disk / cache | +| [`serialize_numpy`][cortex.utils.serialization.serialize_numpy] / [`deserialize_numpy`][cortex.utils.serialization.deserialize_numpy] | Raw array round-trip without msgpack overhead | +| `Message.to_frames` / `Message.from_frames` | Anything inside Cortex itself | + +## See also + +- [Concepts → Message wire format](../concepts/message-wire-format.md) +- [Concepts → Fingerprinting](../concepts/fingerprinting.md) +- [Guides → Performance tuning](../guides/performance-tuning.md) diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md new file mode 100644 index 0000000..ef5159a --- /dev/null +++ b/docs/concepts/architecture.md @@ -0,0 +1,96 @@ +# Architecture + +Cortex has three moving parts: the **discovery daemon**, **publisher** nodes, +and **subscriber** nodes. They coordinate over ZeroMQ — a REQ/REP control plane +for discovery and a PUB/SUB data plane for messages. + +## High-level view + +```mermaid +flowchart TB + subgraph CP[Control plane] + DD[Discovery daemon
ipc:///tmp/cortex/discovery.sock] + end + + subgraph DP[Data plane] + direction LR + P[Publisher node] -- "PUB / SUB (IPC)" --> S[Subscriber node] + end + + P -- REGISTER --> DD + S -- LOOKUP --> DD + DD -- TopicInfo --> S + + classDef daemon fill:#6366f1,stroke:#312e81,color:#fff + classDef node fill:#0ea5e9,stroke:#0369a1,color:#fff + class DD daemon + class P,S node +``` + +## Message journey + +Tracing one frame end to end: + +```mermaid +sequenceDiagram + autonumber + participant User as User code + participant Pub as Publisher + participant Sock as ZMQ PUB socket + participant Net as IPC + participant SSock as ZMQ SUB socket + participant Sub as Subscriber + participant CB as async callback + + User->>Pub: publish(Message) + Pub->>Pub: build header (fingerprint, ts, seq) + Pub->>Pub: encode field values + OOB buffers + Pub->>Sock: send_multipart([topic, header, metadata, *buffers]) + Sock->>Net: zero-copy handoff + Net->>SSock: frames delivered + SSock->>Sub: recv_multipart(copy=False) + Sub->>Sub: Message.from_frames(...) + Sub->>CB: await callback(msg, header) +``` + +Key invariant: array buffers ride as **separate ZMQ frames**, not inline in the +metadata. See [Message wire format](message-wire-format.md). + +## Process layout + +```mermaid +flowchart LR + subgraph P1[Process: sensor] + N1[Node
shared zmq.asyncio.Context] + PUB1[Publisher /sensor/a] + PUB2[Publisher /sensor/b] + T1[Timer 30 Hz] + N1 --> PUB1 + N1 --> PUB2 + N1 --> T1 + end + + subgraph P2[Process: processor] + N2[Node] + SUB1[Subscriber /sensor/a] + SUB2[Subscriber /sensor/b] + PUB3[Publisher /processed] + N2 --> SUB1 + N2 --> SUB2 + N2 --> PUB3 + end + + PUB1 -.->|IPC| SUB1 + PUB2 -.->|IPC| SUB2 +``` + +Each topic gets its own IPC socket under `/tmp/cortex/topics/`. A single `Node` +shares one `zmq.asyncio.Context` across all its publishers and subscribers to +avoid per-socket io thread overhead. + +## See also + +- [Message wire format](message-wire-format.md) +- [Fingerprinting](fingerprinting.md) +- [Discovery protocol](discovery-protocol.md) +- [Async execution model](async-execution-model.md) diff --git a/docs/concepts/async-execution-model.md b/docs/concepts/async-execution-model.md new file mode 100644 index 0000000..4449ca7 --- /dev/null +++ b/docs/concepts/async-execution-model.md @@ -0,0 +1,88 @@ +# Async execution model + +Cortex nodes are asyncio-native. One event loop per process drives all +publishers, subscribers, and timers for that node. On Linux and macOS, +[`cortex.run`][cortex.utils.loop.run] prefers `uvloop` for lower tail latency. + +## Node task graph + +```mermaid +flowchart TB + Loop(((asyncio event loop))) + Loop --> T1[Timer 1
RateExecutor] + Loop --> T2[Timer 2
RateExecutor] + Loop --> S1[Subscriber 1
AsyncExecutor] + Loop --> S2[Subscriber 2
AsyncExecutor] +``` + +`Node.run()` spawns one task per timer (`RateExecutor`) and one per +callback-bearing subscriber (`AsyncExecutor`). It then `asyncio.gather`s them +until cancelled. + +## `RateExecutor` cadence + +```mermaid +sequenceDiagram + participant L as Event loop + participant R as RateExecutor + participant CB as callback + + loop every interval + L->>R: resume + R->>CB: await callback() + R->>R: next_exec_time += interval + alt fell behind + R->>R: next_exec_time = now + interval + end + R->>L: sleep(next_exec_time - now) + end +``` + +Catch-up logic **silently drops ticks** when a callback overruns its period — +something to keep in mind for control loops. + +## `AsyncExecutor` receive loop + +```mermaid +sequenceDiagram + participant L as Event loop + participant A as AsyncExecutor + participant S as SUB socket + participant CB as user callback + + loop while running + L->>A: resume + A->>S: await recv_multipart(copy=False) + S-->>A: frames + A->>A: decode message + A->>CB: await callback(msg, header) + A->>L: sleep(0) (yield) + end +``` + +!!! warning "Head-of-line blocking" + A slow callback stalls the receive loop. Messages pile up on the SUB HWM + and get evicted. If you expect variable-latency work, offload callback + bodies to `asyncio.create_task(...)` or a thread pool. + +## Publish is sync-inside-async + +The `Publisher` uses a sync `zmq.Context` (shadowed onto the node's async +context). `publish()` is a plain function call — no `await`. This avoids the +overhead of the async zmq integration on the send path. + +!!! danger "Not thread-safe" + A `zmq.PUB` socket is not safe to call from multiple threads or tasks + concurrently. Serialize calls to `publish()` per publisher. + +## uvloop + +On Unix, importing `cortex.run` checks for `uvloop` and uses it if present. +Measured impact: modest throughput improvement, meaningful p99 latency +reduction on high-rate small messages. + +## See also + +- [`cortex.core.executor`](../reference/core/executor.md) +- [`cortex.core.node`](../reference/core/node.md) +- [Components → Node & Executors](../components/node-and-executors.md) diff --git a/docs/concepts/discovery-protocol.md b/docs/concepts/discovery-protocol.md new file mode 100644 index 0000000..9a73108 --- /dev/null +++ b/docs/concepts/discovery-protocol.md @@ -0,0 +1,111 @@ +# Discovery protocol + +The discovery daemon speaks a tiny msgpack-over-REQ/REP protocol. It is not +on the data path — once a subscriber has the endpoint, messages flow +publisher → subscriber directly. + +## Commands + +| Command | Payload required | Returns | +| ------------------------------ | ------------------------ | ------------------ | +| `REGISTER_TOPIC` (1) | [`TopicInfo`][cortex.discovery.protocol.TopicInfo] | OK / ALREADY_EXISTS | +| `UNREGISTER_TOPIC` (2) | `topic_name` or `TopicInfo.name` | OK / NOT_FOUND | +| `LOOKUP_TOPIC` (3) | `topic_name` | OK + `TopicInfo` / NOT_FOUND | +| `LIST_TOPICS` (4) | — | OK + `list[TopicInfo]` | +| `SHUTDOWN` (99) | — | OK; daemon exits | + +Status codes: `OK=0`, `NOT_FOUND=1`, `ALREADY_EXISTS=2`, `ERROR=3`. + +## `TopicInfo` payload + +```python +@dataclass +class TopicInfo: + name: str # "/camera/image" + address: str # "ipc:///tmp/cortex/topics/cam__camera_image.sock" + message_type: str # "ImageMessage" + fingerprint: int # 64-bit class fingerprint + publisher_node: str # "cam" +``` + +## Publisher register flow + +```mermaid +sequenceDiagram + autonumber + participant P as Publisher + participant D as Daemon REP + + P->>P: bind PUB socket on ipc:///tmp/cortex/topics/__.sock + P->>D: REQ → DiscoveryRequest(REGISTER_TOPIC, TopicInfo{...}) + D->>D: if topic_name absent: insert; else compare publisher_node + alt new + D-->>P: OK "Registered topic: /x" + else same publisher re-registering + D-->>P: OK (overwrite) + else different publisher, same topic + D-->>P: ALREADY_EXISTS + end +``` + +## Subscriber lookup flow + +```mermaid +sequenceDiagram + autonumber + participant S as Subscriber + participant D as Daemon REP + participant P as Publisher + + S->>D: REQ → LOOKUP_TOPIC("/x") + alt present + D-->>S: OK + TopicInfo + S->>P: SUB connect + SUBSCRIBE "/x" + else missing + D-->>S: NOT_FOUND + Note over S: if wait_for_topic:
poll every 500 ms until timeout + S->>D: retry LOOKUP_TOPIC + end +``` + +`wait_for_topic_async` implements the retry loop with `asyncio.sleep` so the +event loop keeps spinning. + +## REQ-socket recovery + +ZMQ `REQ` sockets enter a bad state after a missed reply — they block further +sends. The client detects `zmq.Again` on timeout and rebuilds the socket: + +```mermaid +flowchart TD + A[send request] -->|timeout| B[REQ socket stuck] + B --> C[close socket] + C --> D[recreate socket
same endpoint] + D --> E[retry up to retries] +``` + +See [`DiscoveryClient._reconnect`][cortex.discovery.client.DiscoveryClient]. + +!!! bug "Fencepost in `retries` default" + `retries=1` today executes the loop exactly once — i.e. no retry. Bump to + `retries=3` in client-side code if you need resilience. + +## Failure modes & how Cortex handles them + +| Scenario | Behavior | +| ---------------------------------------- | --------------------------------------------- | +| Daemon not running when publisher starts | Register fails; publisher still publishes, but no subscriber can find it. | +| Daemon restarts | All state lost; publishers must re-register. Current design has no auto-re-register. | +| Publisher crashes | Registry keeps stale `TopicInfo` until someone UNREGISTERs. | +| Two publishers, same topic | Second registration rejected with `ALREADY_EXISTS`. | +| Subscriber looks up before publisher | `NOT_FOUND`; caller may `wait_for_topic` to poll. | + +Roadmap items (see [critique.md](../critique.md)) to address these: leases with +heartbeats, multi-publisher support, and notify-on-change. + +## See also + +- [`cortex.discovery.protocol`](../reference/discovery/protocol.md) +- [`cortex.discovery.client`](../reference/discovery/client.md) +- [`cortex.discovery.daemon`](../reference/discovery/daemon.md) +- [Components → Discovery](../components/discovery.md) diff --git a/docs/concepts/fingerprinting.md b/docs/concepts/fingerprinting.md new file mode 100644 index 0000000..b442c22 --- /dev/null +++ b/docs/concepts/fingerprinting.md @@ -0,0 +1,104 @@ +# Fingerprinting + +Every message class gets a **64-bit identifier** derived from its name and +field schema. The fingerprint rides in the header of every published message +and does two jobs: + +1. **Type dispatch** — `Message.decode(bytes)` looks up the right class in the + [`MessageType`][cortex.messages.base.MessageType] registry. +2. **Compatibility check** — subscribers verify that the topic they looked up + advertises the same fingerprint as the type they were written against. + +## Derivation + +```mermaid +flowchart LR + A[class.__module__ + qualname] --> C[canonical string] + B[sorted list of field:type] --> C + C --> H[SHA-256] + H --> F[first 8 bytes → u64 big-endian] +``` + +Pseudocode: + +```python +canonical = f"{cls.__module__}.{cls.__qualname__}|{','.join(sorted('name:type'))}" +fingerprint = int.from_bytes(sha256(canonical.encode()).digest()[:8], "big") +``` + +The result is cached per-class in `_fingerprint_cache`, computed once lazily. + +## Registry + +`Message.__init_subclass__` auto-registers every concrete subclass into +[`MessageType._registry`][cortex.messages.base.MessageType] keyed by +fingerprint. Nothing else to do — decorating your dataclass with +`@dataclass` and inheriting from `Message` is enough. + +```python +from dataclasses import dataclass +from cortex.messages.base import Message + +@dataclass +class JointState(Message): + positions: list[float] + velocities: list[float] + +print(hex(JointState.fingerprint())) +``` + +## When fingerprints change + +The fingerprint is **not stable across edits that touch**: + +- Module path or class name (`cortex.messages.standard.ArrayMessage` renamed + anywhere). +- Field names. +- Field *type annotations as spelled* (see the PEP 563 caveat below). + +It is stable across: + +- Adding/removing unrelated classes. +- Reordering methods. +- Changing docstrings or default values. + +## Subscriber check + +On connect, the subscriber compares the topic's advertised fingerprint against +the one it computed from its message class: + +```mermaid +sequenceDiagram + participant S as Subscriber + participant D as Discovery daemon + + S->>D: LOOKUP /topic + D-->>S: TopicInfo(fingerprint=0xABCD...) + S->>S: compare with MyMessage.fingerprint() + alt mismatch + S-->>S: log warning, continue anyway + else match + S-->>S: connect and subscribe + end +``` + +!!! warning "Today: mismatch is a warning, not an error" + A fingerprint mismatch currently only logs a warning — see [critique.md](../critique.md). + Downstream decoding will fail hard. Until that is tightened, prefer to + re-exchange type definitions between processes rather than rely on this guard. + +## PEP 563 caveat + +`field.type` may be a **string** (under `from __future__ import annotations`) +or a **real type** otherwise. The canonical string differs in the two cases, +so the same class can fingerprint differently across import environments. + +When defining messages shared between processes, either use the same import +style in both, or rely on the runtime `typing.get_type_hints(cls)` equivalent +once that lands upstream. + +## See also + +- [`cortex.utils.hashing`](../reference/utils/hashing.md) — `compute_fingerprint`, cache helpers +- [Message wire format](message-wire-format.md) +- [Critique § code-level issue 13](../critique.md) diff --git a/docs/concepts/message-wire-format.md b/docs/concepts/message-wire-format.md new file mode 100644 index 0000000..48a4ac0 --- /dev/null +++ b/docs/concepts/message-wire-format.md @@ -0,0 +1,96 @@ +# Message wire format + +Cortex uses **ZeroMQ multipart messages**. Each published message is a list of +frames rather than a single blob. That lets array payloads ride as raw +contiguous buffers — no copy into a Python `bytes`, no re-copy by ZMQ. + +## Frames on the wire + +```mermaid +flowchart LR + F0["Frame 0
topic bytes"] --> F1 + F1["Frame 1
header (24B)
fingerprint • ts_ns • seq"] --> F2 + F2["Frame 2
msgpack metadata
(ordered field values)"] --> F3 + F3["Frame 3..N
raw array buffers
(OOB, zero-copy)"] +``` + +| Frame | Contents | Size | +| ------- | ---------------------------- | ------------ | +| 0 | Topic name (UTF-8) | variable | +| 1 | [`MessageHeader`][cortex.messages.base.MessageHeader] | **24 bytes** (3 × u64, big-endian) | +| 2 | msgpack-packed ordered field values; arrays replaced by OOB descriptors | small | +| 3..N | `np.ndarray.tobytes()` / `tensor.numpy().tobytes()`, contiguous | payload-sized | + +## Header layout + +``` +offset 0 8 16 24 + |fp u64 |ts u64 |seq u64 | + big-endian throughout +``` + +- `fp` — 64-bit message fingerprint, computed from class name and field schema. +- `ts` — publisher wall-clock in nanoseconds (`time.time_ns()`). +- `seq` — per-process, per-message-type monotonic counter. + +## Metadata (Frame 2) + +Field values are packed **in declaration order** (not by name), so the receiver +reconstructs using the dataclass's cached field tuple. This removes per-message +field-name encoding. + +Arrays and tensors appear in the metadata as small dict stand-ins called +**OOB descriptors**: + +```json +{ + "__cortex_oob__": "numpy", + "buffer": 0, + "dtype": ">M: build header + collect field values + M->>S: values in declaration order + S->>E: for each value, walk nested dicts/lists + E-->>S: scalar stays inline; array → OOB descriptor + buffer appended + S-->>M: (metadata_bytes, [buf0, buf1, ...]) + M-->>Z: [topic, header, metadata, *buffers] +``` + +## The legacy single-blob path + +`Message.to_bytes()` / `from_bytes()` / `Message.decode()` still exist. They +pack *everything* into one msgpack blob using `ExtType` for arrays. That path +is retained for tests and opportunistic use; the transport always uses the +multipart path above. + +!!! warning "Mismatch trap" + Bytes captured from the wire cannot be fed to `Message.decode()` — the wire + format is multipart, not a single blob. Use `Message.from_frames(frames)`. + +## See also + +- [Fingerprinting](fingerprinting.md) +- [`cortex.utils.serialization`](../reference/utils/serialization.md) — encoding helpers +- [`cortex.messages.base`](../reference/messages/base.md) — `Message`, `MessageHeader` diff --git a/docs/concepts/transport-and-qos.md b/docs/concepts/transport-and-qos.md new file mode 100644 index 0000000..766b669 --- /dev/null +++ b/docs/concepts/transport-and-qos.md @@ -0,0 +1,33 @@ +# Transport & QoS + +*Stub — deep dive coming in a later pass.* + +## Current socket settings + +| Socket | Option | Value | Notes | +| ------------ | ------------- | ----- | ------------------------------------- | +| Publisher PUB | `SNDHWM` | 10 (default `queue_size`) | Drops under backpressure | +| Publisher PUB | `LINGER` | 0 | Immediate close | +| Subscriber SUB | `RCVHWM` | 10 | Oldest messages evicted when full | +| Subscriber SUB | `LINGER` | 0 | | +| Daemon REP | `RCVTIMEO` | 1000 ms | Allows Ctrl-C responsiveness | +| Daemon REP | `LINGER` | 0 | | + +## Today's delivery semantics + +- Publisher uses `zmq.NOBLOCK`: if the send queue is full, the message is + **silently dropped**. +- Subscriber HWM is a ring buffer: old messages are **silently evicted** on + overflow. + +This is fine for best-effort telemetry. It is unsafe for control commands. + +## Planned QoS profiles + +Taking inspiration from DDS, three profiles are enough for most robotics use: + +- `best_effort_latest` — conflate; keep only newest (camera frames). +- `reliable_queue` — publisher blocks or errors (control commands). +- `dropping_queue` — current behavior with an exposed drop counter (telemetry). + +See [critique.md § 4](../critique.md) for rationale. diff --git a/docs/critique.md b/docs/critique.md new file mode 100644 index 0000000..6677641 --- /dev/null +++ b/docs/critique.md @@ -0,0 +1,146 @@ +# Cortex Critique + +A bottom-up review of Cortex as it stands today, with a focus on its viability as a communication library for robotics. This complements [design-review.md](design-review.md) with concrete code-level findings and benchmark observations. + +## How Cortex works (bottom-up) + +### 1. Fingerprinting — `utils/hashing.py` + +A message class's identity is a 64-bit integer: + +``` +fingerprint = SHA-256(f"{module}.{qualname}|{','.join(sorted('field:type'))}")[:8] +``` + +- Computed lazily and cached in `_fingerprint_cache`. +- `field.type` is a string when `from __future__ import annotations` is active and a real type otherwise. The fingerprint therefore depends on how the module was imported — fragile for cross-repo use. +- Field ordering is sorted alphabetically in the fingerprint, but the wire layout uses dataclass declaration order. Two classes could theoretically fingerprint identically but interpret the wire differently. + +### 2. Message base — `messages/base.py` + +Each dataclass inheriting `Message` is auto-registered via `__init_subclass__` into `MessageType._registry[fingerprint] = cls`. + +Wire format (multipart transport, what publishers actually use): + +``` +Frame 0: topic bytes (for PUB/SUB filter) +Frame 1: 24-byte header (fingerprint u64, timestamp_ns u64, sequence u64, big-endian) +Frame 2: msgpack of ordered field values with OOB descriptors +Frame 3..N: raw contiguous array buffers (zero-copy) +``` + +There is a second, legacy single-blob path (`to_bytes` / `from_bytes`) that embeds array bytes inside a single msgpack blob using ExtType. It is retained for `Message.decode(...)` and tests, but is not what the transport uses. + +### 3. Serialization — `utils/serialization.py` + +Two strategies coexist: + +- `_msgpack_default` / `_msgpack_ext_hook` (inline): arrays/tensors get packed as msgpack ExtType inside the single blob. Used by the legacy path. +- `_encode_transport_value` / `_decode_transport_value` (out-of-band): each array/tensor is replaced with a tiny dict `{__cortex_oob__: "numpy", buffer: i, dtype, shape}` and its raw bytes are appended as separate ZMQ frames. Reconstruction uses `np.frombuffer(frame.buffer, dtype).reshape(shape)` with no copy. + +After the March 2026 optimizations: zero-copy decode, schema-ordered values (field names no longer repeated per message), and cached field-name tuples. + +### 4. Discovery — `discovery/daemon.py` and `discovery/client.py` + +Single-threaded `zmq.REP` over IPC at `ipc:///tmp/cortex/discovery.sock`. + +- Registry is a plain `dict[str, TopicInfo]`, enforcing one publisher per topic. +- RCVTIMEO=1s so the run loop can poll `_running` for Ctrl-C. +- Commands: REGISTER, UNREGISTER, LOOKUP, LIST, SHUTDOWN. +- Request/response payloads are msgpack. +- Client uses REQ with close-and-recreate on timeout (REQ sockets are stuck after a missed reply). + +### 5. Publisher / Subscriber — `core/publisher.py`, `core/subscriber.py` + +- **Publisher**: binds a `zmq.PUB` at `ipc:///tmp/cortex/topics/__.sock`, registers via the discovery client, publishes multipart `[topic, header, metadata, *buffers]` with `zmq.NOBLOCK`. If the `Node` hands it an async context, it wraps a sync `zmq.Context(self._context)` around the same underlying zmq io threads so publishing stays synchronous. +- **Subscriber**: uses an async context, looks up the topic (optionally waits), connects `zmq.SUB`, sets a topic filter, loops via `AsyncExecutor` doing `recv_multipart(copy=False)` → `Message.from_frames`. + +### 6. Node + Executors — `core/node.py`, `core/executor.py` + +A `Node` owns a shared `zmq.asyncio.Context`, plus lists of publishers, subscribers, and timers. Each timer gets a `RateExecutor(fn, rate_hz)`. `node.run()` creates asyncio tasks for every timer and every callback-subscriber, then `asyncio.gather`. `RateExecutor` uses `perf_counter` plus `asyncio.sleep(max(0, next-now))`. `cortex.run` prefers uvloop on Unix. + +## Benchmark results + +Measured on this machine with the in-repo benchmark suite: + +| Metric | Value | +| ------------------------- | --------------------------- | +| Small-payload latency | mean 556 µs, p99 1075 µs | +| 64KB latency | mean 919 µs, p99 1.4 ms | +| Tiny array throughput | 21.8k msg/s | +| 1MB array throughput | 7.7k msg/s, 8.0 GB/s | +| 4MB array throughput | 2.25k msg/s, 9.4 GB/s | +| 1080p RGB frames | 1422 fps, 8.8 GB/s | +| Raw wire+decode (inproc) | 35 µs roundtrip (4MB array) | + +The delta between the **~35 µs raw wire** and **~550 µs end-to-end** is asyncio scheduling, context-switch between publisher timer and subscriber recv, and Python callback dispatch. Serialization is close to memcpy-bandwidth on large payloads — the OOB transport is pulling its weight. + +## What can be improved + +### Design-level (biggest wins) + +1. **Latency floor is too high for control loops.** ~550 µs mean and ~1.5 ms p99 is dominated by `asyncio` + `zmq.asyncio`, not zmq itself. Control topics should be able to opt into a synchronous thread-plus-`zmq.Poller` receive path targeting <100 µs p99. Async should be the default, not the only option. + +2. **Discovery is a single REQ/REP chokepoint with stop-the-world semantics.** On crashes, stale topic entries are never reclaimed — a crashed publisher's IPC file stays on disk and the registry keeps pointing at a dead socket. Add leases with heartbeats (publisher renews every N seconds; daemon evicts stale entries), or a peer-gossip model where every node beacons presence. The current daemon has no concurrency — one slow client blocks all others. + +3. **One-publisher-per-topic is a hard limit for robotics.** Redundant IMUs, failover, and multi-source fusion are all blocked. The registry should accept N publishers per topic and subscribers should `connect()` to all of them — ZMQ SUB handles fan-in natively. + +4. **No backpressure semantics.** `pub.publish()` is `NOBLOCK` and silently drops on HWM. Subscriber HWM=10 on SUB evicts old messages by default. Robotics needs per-topic QoS profiles similar to DDS: + - `best_effort_latest` — camera frames: drop old, keep newest (`ZMQ_CONFLATE=1`). + - `reliable_queue` — commands: block or surface an error. + - `dropping_queue` — telemetry: current behavior, but with a drop counter. + +5. **No liveness or drop detection.** A subscriber has no way to know the publisher died. Sequence numbers exist in the header but are never checked for gaps. Automatic gap-counting in Subscriber would be gold for debugging. + +6. **Callback execution blocks the receive loop.** A 10 ms callback accumulates on SUB HWM and drops. Receive, decode, and user-callback execution should be decoupled with a bounded work queue and one or more worker coroutines/threads per subscriber. ROS 2 executors have this distinction for a reason. + +7. **Local-only transport in practice.** Addresses are hardcoded `ipc://` paths under `/tmp`. Multi-host robotics (robot ↔ base-station) needs TCP transport in discovery, NIC selection, and topology-aware addressing. + +8. **No shared memory for huge payloads.** At 9 GB/s on 4 MB arrays, every subscriber gets a fresh copy. For multi-subscriber camera or LiDAR fan-out, a shared-memory transport (posix shm + ring buffer + zmq for control-plane notifications) would give true zero-copy. + +### Code-level issues + +9. `publisher.py:91-95` — `zmq.Context(self._context)` creates a shadowed sync context sharing the async context's io threads. Correct, but subtle. `zmq.PUB` is **not thread-safe** — calling `pub.publish()` from multiple asyncio tasks on the same socket is undefined. Needs docs or a lock. + +10. `publisher.py:117-118` — the publisher unlinks any existing socket file on startup. If two publishers on the same host use the same node name + topic, the second silently steals the socket. Should fail loudly. + +11. `subscriber.py:155-160` — fingerprint mismatch logs a warning and proceeds anyway. That is a silent-data-corruption path. Should refuse to connect. + +12. `messages/base.py:109-129` — `_sequence_counter` is **class-level**, shared across every Publisher instance of that message type in the process. Two publishers of `ArrayMessage` interleave sequences — breaking per-topic drop detection. Move it onto the `Publisher`. + +13. `utils/hashing.py:34-38` — `field.type` is a string with PEP 563 and a real type otherwise; the resulting fingerprint differs across import environments. Use `typing.get_type_hints(cls)` consistently. + +14. `discovery/client.py:78-101` — `retries=1` default means zero retries (loop runs once). Fencepost bug. + +15. `core/executor.py:119-147` — `RateExecutor` has both `await asyncio.sleep(0)` inside the loop and `await asyncio.sleep(max(0, dt))` at the bottom. The first is redundant and creates unnecessary wakeups. Catch-up logic silently eats dropped ticks; control loops often need to know. + +16. `discovery/daemon.py:87` — RCVTIMEO=1s means Ctrl-C takes up to 1s to take effect and request throughput is throttled. A `zmq.Poller` with a shutdown PAIR socket gives clean immediate shutdown. + +17. `messages/standard.py:146-150` — `ImageMessage.__post_init__` auto-fill is non-idempotent across deserialization round-trips. Minor. + +18. `discovery/daemon.py:168-177` — same-publisher re-registration is allowed; if its IPC path changed, existing subscribers are never told. Needs a lease or a "changed" notification. + +19. **No CI test for cross-process fingerprint stability.** Given how much safety rides on fingerprints, every standard message type deserves a stored golden fingerprint asserted in CI. + +20. **`from_bytes` vs `from_frames` asymmetry is a trap.** `Message.decode(bytes)` only handles the inline path. If anyone captures bytes from the wire (the multipart path) and calls `decode()`, it will fail silently. Unify the paths or rename `decode`. + +21. **No async publish.** `send_multipart` briefly blocks on HWM/context switch; inside an async timer callback this is a hidden blocking call. An async `publish` variant would help. + +### Schema evolution + +22. No optional fields, no versioning. For long-lived robotics deployments, add: + - field defaults (so fingerprints tolerate missing trailing fields on decode), + - an `msg_schema_version: int = 1` convention, + - eventually, a real wire schema (FlatBuffers, Cap'n Proto, or generated-from-.fbs dataclasses). + +## Summary + +Cortex is a well-built, honest small-system IPC library. The **serialization is genuinely fast** — hitting memcpy-bandwidth on 4 MB arrays with zero-copy OOB frames. The **latency floor (~550 µs p50, ~1.5 ms p99)** is limited by asyncio, not zmq. The **discovery, QoS, liveness, and single-host assumptions** are the real blockers for using this as robotics middleware. + +Recommended path if adopting Cortex for robotics: + +1. Add per-topic QoS profiles with drop counters (1-2 days). +2. Add a synchronous-threaded subscriber option for low-latency control (1 day). +3. Add heartbeats/leases and multi-publisher support to discovery (3-5 days). +4. Add TCP transport and host-aware discovery (2-3 days). +5. Then consider shared memory and schema evolution. diff --git a/docs/design-review.md b/docs/design-review.md deleted file mode 100644 index a978577..0000000 --- a/docs/design-review.md +++ /dev/null @@ -1,155 +0,0 @@ -# Cortex Design Review - -This is a candid review of the current Cortex architecture as it exists today. - -## What The Design Gets Right - -### 1. The mental model is clean - -`Node`, `Publisher`, `Subscriber`, and `Executor` are easy to understand. That matters in robotics because most teams are already debugging hardware, timing, and perception stacks. They do not need a messaging library that is conceptually expensive. - -### 2. ZeroMQ over IPC is a good local-first choice - -For single-machine robotics workloads, IPC sockets are a practical baseline: - -- deployment is simple, -- dependencies are light, -- latency is decent, -- failure modes are understandable. - -This is substantially more pragmatic than trying to start with a distributed RPC stack. - -### 3. Message typing is lightweight but useful - -The fingerprinting approach gives you a useful form of type identity without requiring code generation. For a Python-first system, that is a reasonable tradeoff. - -### 4. The design is honest about being Pythonic - -This library is not pretending to be a hard-real-time transport. That is good. A lot of robotics middleware gets worse when it makes timing promises the language runtime cannot actually keep. - -## What The Design Gets Wrong - -### 1. The discovery daemon is a single chokepoint - -The discovery plane is centralized, synchronous, and stateful. That is acceptable for a toy system or a small workstation setup. It is not a strong foundation for a larger graph. - -Problems: - -- one daemon is a single point of failure, -- REQ/REP forces lockstep request handling, -- startup storms can serialize on a single socket, -- there is no notion of leases, heartbeats, or stale publisher reclamation beyond best-effort unregister. - -This will become painful the moment processes crash uncleanly, restart frequently, or run across multiple machines. - -### 2. Topic ownership is effectively one-publisher-per-topic - -That is a serious limitation. Many robotics systems eventually need: - -- redundant publishers, -- replicated sensors, -- failover, -- multiple producers feeding a shared stream. - -The current registry rejects competing publishers by topic. That is simple, but it is also a structural dead end for anything beyond a tightly controlled single-producer graph. - -### 3. The transport is local-only in practice - -The architecture and defaults are built around IPC paths under `/tmp`. That is fine for a workstation. It does not scale to: - -- multi-host robots, -- edge-to-base-station links, -- containerized deployments with namespace boundaries, -- cloud or cluster processing. - -The code says “distributed systems” in spirit, but the design is really “single-host process graph with discovery.” Those are not the same thing. - -### 4. Backpressure policy is underspecified - -Queue size exists, but the system does not expose a strong contract about what happens under overload. - -Important unanswered questions: - -- Are old messages dropped or new ones? -- Should publishers block or fail fast? -- Should subscribers conflate or queue? -- What is the intended behavior for high-rate sensors versus low-rate control topics? - -Without explicit backpressure semantics, the system behaves however ZeroMQ happens to behave under current socket settings. That is not design. That is delegation. - -### 5. Callback execution is too naive for serious pipelines - -Subscribers process callbacks in a simple async loop. That works for demos. It breaks down when callbacks become non-trivial. - -Problems: - -- one slow callback can stall message handling, -- there is no built-in bounded work queue, -- there is no separation between receive, decode, and user callback execution, -- there is no scheduler policy per topic. - -As graphs grow, this turns into timing jitter and head-of-line blocking. - -### 6. Message compatibility is brittle - -Fingerprinting based on Python field structure is convenient, but it is not a robust schema evolution story. - -You do not currently have a first-class answer for: - -- optional fields added over time, -- backwards-compatible evolution, -- deprecation windows, -- cross-language interoperability, -- stable wire contracts. - -This is acceptable for internal experiments. It is weak for long-lived systems. - -## Scaling Problems You Will Hit - -### 1. Process count scaling - -As node count rises, discovery traffic and socket management get noisier. Even if steady-state data bypasses discovery, startup and restart behavior will degrade first. - -### 2. Topic count scaling - -Per-topic socket endpoints become operationally messy at larger scale. Many topics means many IPC files, many bindings, and more cleanup edge cases after crashes. - -### 3. Payload size scaling - -Large images, point clouds, and model tensors will keep stressing Python object creation, buffer ownership, and callback scheduling even after the current serialization improvements. Shared memory is not required yet, but eventually this design will force you toward it. - -### 4. Multi-machine scaling - -The current design will need more than minor edits to become a robust networked middleware. You will need to rethink: - -- discovery, -- transport security, -- reconnection semantics, -- publisher liveness, -- addressability, -- observability. - -### 5. Reliability scaling - -There is little explicit modeling of delivery guarantees, replay, durability, or health monitoring. For robotics this is survivable in best-effort telemetry. It is much less acceptable for supervisory control, autonomy arbitration, or safety-critical status propagation. - -## Brutal Summary - -Cortex is a good small-system local IPC library. - -It is not yet a mature robotics middleware. - -Its strengths are simplicity, low ceremony, and a sensible local-first transport choice. Its weaknesses are exactly the ones that appear when a clean prototype meets scale: centralized discovery, simplistic scheduling, underspecified overload behavior, weak schema evolution, and single-host assumptions baked into the architecture. - -If the goal is a fast Python-native pub/sub layer for one machine or a tightly managed robot process graph, the design is good enough and now materially faster on large payloads. - -If the goal is something that competes with established robotics middleware as systems become larger, more distributed, and more failure-prone, the current design will run into hard walls rather than soft inefficiencies. - -## Concrete Architectural Next Steps - -1. Define transport semantics per topic class: telemetry, control, state, bulk sensor, debug. -2. Introduce explicit backpressure and drop policies instead of relying on implicit socket behavior. -3. Add publisher liveness or lease-based discovery so stale registrations disappear automatically. -4. Decouple receive, decode, and callback execution with bounded queues. -5. Decide whether Cortex is intentionally single-host or genuinely multi-host, then design for that decision instead of straddling both. -6. Add a real schema evolution strategy before message types spread across projects. \ No newline at end of file diff --git a/docs/gen_ref_pages.py b/docs/gen_ref_pages.py new file mode 100644 index 0000000..a6c4f5a --- /dev/null +++ b/docs/gen_ref_pages.py @@ -0,0 +1,47 @@ +"""Generate one API reference page per module under ``src/cortex/``. + +Executed by ``mkdocs-gen-files`` during the build. Emits: + +- ``reference//.md`` for every non-dunder module, +- ``reference//index.md`` for every ``__init__.py``, +- ``reference/SUMMARY.md`` consumed by ``mkdocs-literate-nav``. + +Keeping this generated means adding a new module needs zero doc edits. +""" + +from pathlib import Path + +import mkdocs_gen_files + +# This script lives at ``docs/gen_ref_pages.py`` and is executed by +# mkdocs-gen-files with the mkdocs.yml directory as cwd. Anchor to the +# repo root so the generator finds ``src/cortex`` regardless of cwd. +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" +PACKAGE = "cortex" + +nav = mkdocs_gen_files.Nav() + +for path in sorted((SRC_ROOT / PACKAGE).rglob("*.py")): + module_path = path.relative_to(SRC_ROOT).with_suffix("") + doc_path = Path("reference", *module_path.parts[1:]).with_suffix(".md") + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav_parts = parts[1:] if parts[1:] else ("cortex",) + nav[nav_parts] = doc_path.relative_to("reference").as_posix() + + identifier = ".".join(parts) if parts else PACKAGE + with mkdocs_gen_files.open(doc_path, "w") as f: + f.write(f"# `{identifier}`\n\n") + f.write(f"::: {identifier}\n") + + mkdocs_gen_files.set_edit_path(doc_path, path.relative_to(REPO_ROOT)) + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as f: + f.writelines(nav.build_literate_nav()) diff --git a/docs/getting-started/discovery-daemon.md b/docs/getting-started/discovery-daemon.md new file mode 100644 index 0000000..518bc55 --- /dev/null +++ b/docs/getting-started/discovery-daemon.md @@ -0,0 +1,69 @@ +# Running the Discovery Daemon + +The discovery daemon is a lightweight REP service that maintains the registry +of active topics. Publishers register on startup; subscribers look up the +endpoint and connect directly. + +## Start + +=== "As a script" + + ```bash + cortex-discovery + ``` + +=== "As a module" + + ```bash + python -m cortex.discovery.daemon + ``` + +=== "As a systemd service" + + ```ini title="/etc/systemd/system/cortex-discovery.service" + [Unit] + Description=Cortex discovery daemon + After=network.target + + [Service] + Type=simple + ExecStart=/usr/bin/env cortex-discovery + Restart=on-failure + RuntimeDirectory=cortex + + [Install] + WantedBy=multi-user.target + ``` + +## Command-line options + +| Flag | Default | Description | +| ------------- | -------------------------------------- | ------------------------------- | +| `--address` | `ipc:///tmp/cortex/discovery.sock` | ZMQ endpoint to bind | +| `--log-level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` | + +## Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Starting: bind REP socket + Starting --> Running: socket ready + Running --> Running: handle REGISTER / LOOKUP / LIST / UNREGISTER + Running --> Stopping: SIGINT or SHUTDOWN command + Stopping --> [*]: close socket, unlink ipc file +``` + +## Troubleshooting + +**"Address already in use"** +: Another daemon (or a stale socket file) is holding the path. + `rm /tmp/cortex/discovery.sock` and restart. + +**Subscribers time out looking up topics** +: Daemon not running, or publisher failed to register. Run with + `--log-level DEBUG` and watch for REGISTER / LOOKUP lines. + +**Daemon crash leaves stale entries** +: Today, entries are only removed on explicit UNREGISTER. A crashed + publisher's topic stays in the registry pointing at a dead socket. + Restarting the daemon clears all state. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..3ea43a0 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,42 @@ +# Installation + +## Requirements + +- Python **3.10+** +- Linux or macOS (Windows works but without `uvloop`) +- ZeroMQ shared library (bundled via `pyzmq`) + +## Install from source + +```bash +git clone https://github.com/sudoRicheek/cortex.git +cd cortex +pip install -e ".[dev]" +``` + +## Optional extras + +=== "PyTorch support" + + ```bash + pip install -e ".[torch]" + ``` + + Enables [`TensorMessage`][cortex.messages.standard.TensorMessage] and + torch-aware serialization paths. + +=== "Everything" + + ```bash + pip install -e ".[all]" + ``` + +## Verify + +```python +import cortex +print(cortex.__version__) +``` + +If that prints a version string, you're ready. Continue to the +[Quickstart](quickstart.md). diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md new file mode 100644 index 0000000..470f12e --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,104 @@ +# Quickstart + +A three-terminal pub/sub loop in under two minutes. + +## 1. Start the discovery daemon + +```bash +cortex-discovery +``` + +Leave it running. This is the single service that maps topic names to +IPC endpoints. + +## 2. Publisher + +```python title="pub.py" +import numpy as np +import cortex +from cortex import Node, ArrayMessage + + +class SensorNode(Node): + def __init__(self): + super().__init__("sensor") + self.pub = self.create_publisher("/sensor/data", ArrayMessage) + self.count = 0 + self.create_timer(0.1, self.tick) # 10 Hz + + async def tick(self): + data = np.random.randn(64, 64).astype("float32") + self.pub.publish(ArrayMessage(data=data, name=f"frame_{self.count}")) + self.count += 1 + + +async def main(): + node = SensorNode() + try: + await node.run() + finally: + await node.close() + + +if __name__ == "__main__": + cortex.run(main()) +``` + +```bash +python pub.py +``` + +## 3. Subscriber + +```python title="sub.py" +import cortex +from cortex import Node, ArrayMessage +from cortex.messages.base import MessageHeader + + +async def on_data(msg: ArrayMessage, header: MessageHeader): + print(f"[{header.sequence}] {msg.name} shape={msg.data.shape}") + + +class ViewerNode(Node): + def __init__(self): + super().__init__("viewer") + self.create_subscriber("/sensor/data", ArrayMessage, callback=on_data) + + +async def main(): + node = ViewerNode() + try: + await node.run() + finally: + await node.close() + + +if __name__ == "__main__": + cortex.run(main()) +``` + +```bash +python sub.py +``` + +## What just happened + +```mermaid +sequenceDiagram + participant P as Publisher + participant D as Discovery daemon + participant S as Subscriber + + P->>D: REGISTER /sensor/data -> ipc:///tmp/cortex/topics/... + S->>D: LOOKUP /sensor/data + D-->>S: ipc:///tmp/cortex/topics/... + S->>P: ZMQ SUB connect + SUBSCRIBE "/sensor/data" + loop 10 Hz + P->>S: multipart [topic, header, metadata, buffer] + S->>S: decode + await on_data(msg, header) + end +``` + +See [Concepts → Architecture](../concepts/architecture.md) for the end-to-end +picture, or jump into a [custom message tutorial](../tutorials/custom-messages.md). diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md new file mode 100644 index 0000000..f7fad29 --- /dev/null +++ b/docs/guides/benchmarks.md @@ -0,0 +1,35 @@ +# Benchmarks + +Cortex ships an in-repo benchmark suite at [`benchmarks/`](https://github.com/sudoRicheek/cortex/tree/main/benchmarks). + +## Run + +```bash +# Terminal 1 +cortex-discovery + +# Terminal 2 +python benchmarks/bench_all.py --output results.json +``` + +Individual benchmarks: + +- `benchmarks/bench_latency.py` — one-way publisher→subscriber latency. +- `benchmarks/bench_throughput.py` — messages/sec and MB/sec. +- `benchmarks/bench_all.py` — full matrix with summary and optional JSON dump. + +## Reading results + +- `p99` is what matters for real-time-ish workloads; `mean` can hide jitter. +- For array workloads, `MB/s` approaching memcpy bandwidth is a good sign + that zero-copy transport is working. +- Serialization overhead via `inproc` sockets with `copy=False` is reported + separately — that isolates the encode/decode path from the network path. + +## Tips + +- Pin publisher and subscriber to separate cores for stable latency numbers. +- Disable Turbo-Boost / set CPU governor to `performance` for reproducible + runs. +- Always measure with the discovery daemon also running (it is off the hot + path but can steal a little cache). diff --git a/docs/guides/debugging.md b/docs/guides/debugging.md new file mode 100644 index 0000000..881e5e9 --- /dev/null +++ b/docs/guides/debugging.md @@ -0,0 +1,58 @@ +# Debugging + +## Subscriber hangs on startup + +Most likely: the daemon is not running, or the topic name is mistyped. +`DiscoveryClient.wait_for_topic_async` polls every 500 ms until the topic +appears or the timeout fires. + +```bash +cortex-discovery --log-level DEBUG +``` + +Watch for `LOOKUP topic: /x -> NOT FOUND`. + +## Publisher "works" but subscriber receives nothing + +ZMQ PUB drops messages for which no matching SUB is connected yet. If your +publisher starts first and publishes immediately, the first few messages are +lost — this is the classic ZMQ slow-joiner problem. + +Workarounds: + +- Have the publisher wait briefly after bind before publishing the first message. +- Have the subscriber wait-for-topic (the default) so it comes up after the + publisher registered. + +## Stale `/tmp/cortex/topics/*.sock` files + +If a publisher exits uncleanly, its IPC socket file remains. Cortex's +`Publisher._setup_socket` unlinks any existing file at the same path on the +**next bind** — so restarting the publisher fixes it. Otherwise: + +```bash +rm /tmp/cortex/topics/.sock +``` + +## Daemon state survives restarts — but doesn't + +The registry is **in-memory**. Restarting the daemon wipes all state; +publishers do not auto-re-register today. Restart your publishers after +restarting the daemon. + +## Fingerprint mismatch warning + +If you see +`Message type mismatch for /x: expected FooMessage, got BarMessage` — +the topic was registered with a different message class. Either rename the +topic or align the classes. + +## Debug logging + +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +Cortex uses standard `logging`. Interesting loggers: `cortex.publisher`, +`cortex.subscriber`, `cortex.node`, `cortex.discovery`, `cortex.discovery.client`. diff --git a/docs/guides/performance-tuning.md b/docs/guides/performance-tuning.md new file mode 100644 index 0000000..ce8828f --- /dev/null +++ b/docs/guides/performance-tuning.md @@ -0,0 +1,37 @@ +# Performance tuning + +Current measured numbers on the repo's benchmark suite (single workstation): + +| Workload | Throughput / latency | +| --------------------- | ------------------------------- | +| Small payload latency | mean 556 µs, p99 1075 µs | +| 1MB array throughput | 7.7k msg/s, 8.0 GB/s | +| 4MB array throughput | 2.25k msg/s, 9.4 GB/s | +| 1080p RGB | 1422 fps, 8.8 GB/s | + +See [Benchmarks guide](benchmarks.md) to reproduce. + +## Copy-on-use + +Decoded NumPy arrays **alias the ZMQ frame memory**. That is what makes +large-payload throughput close to memcpy bandwidth — but it means: + +- If you intend to mutate the array, `arr = arr.copy()` first. +- If you intend to hold the array past the callback, copy it first. + +## Queue sizing + +Per-socket HWM defaults to 10. Increase `queue_size` on high-rate producers +whose subscribers are known to be slow — but remember that ZMQ drops silently +at the HWM. + +## When to prefer the inline path + +Single tiny messages (primitives only, < 1 KB) see no benefit from multipart. +The inline `to_bytes` path is still fine there. Publishers always use +multipart today. + +## uvloop + +Installed by default on Unix. Drops tail latency on high-rate small messages +noticeably. No action needed. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a0a2a64 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,83 @@ +# Cortex + +**A lightweight Python framework for inter-process communication over ZeroMQ.** + +Cortex is a pub/sub layer designed to feel obvious. Nodes publish typed messages on named topics; subscribers receive them via async callbacks. A tiny discovery daemon tells subscribers where to connect. Native support for NumPy arrays and PyTorch tensors keeps robotics- and ML-shaped payloads fast. + +
+ +- :material-rocket-launch-outline: **[Getting started](getting-started/quickstart.md)** + + Install, start the daemon, publish your first message in under two minutes. + +- :material-book-open-variant: **[Concepts](concepts/architecture.md)** + + How the wire format, fingerprinting, discovery handshake, and async execution fit together. + +- :material-puzzle-outline: **[Components](components/messages.md)** + + Deep dives into the Messages, Discovery, and Core modules. + +- :material-api: **[API reference](reference/index.md)** + + Auto-generated from the source. Always matches the code on `main`. + +
+ +## Highlights + +- **Publisher / Subscriber pattern** over ZeroMQ PUB/SUB sockets. +- **Discovery service** for automatic topic → endpoint resolution. +- **IPC transport** with zero-copy frames for large NumPy / PyTorch payloads. +- **64-bit fingerprint hashing** for fast message-type identification. +- **uvloop-backed async** on Linux/macOS for lower tail latency. + +## Minimal example + +=== "Publisher" + + ```python + import numpy as np + import cortex + from cortex import Node, ArrayMessage + + + class Cam(Node): + def __init__(self): + super().__init__("cam") + self.pub = self.create_publisher("/cam/frame", ArrayMessage) + self.create_timer(1 / 30, self.tick) + + async def tick(self): + self.pub.publish(ArrayMessage(data=np.random.randn(480, 640).astype("f4"))) + + + cortex.run(Cam().run()) + ``` + +=== "Subscriber" + + ```python + import cortex + from cortex import Node, ArrayMessage + from cortex.messages.base import MessageHeader + + + async def on_frame(msg: ArrayMessage, header: MessageHeader): + print(f"seq={header.sequence} shape={msg.data.shape}") + + + class Viewer(Node): + def __init__(self): + super().__init__("viewer") + self.create_subscriber("/cam/frame", ArrayMessage, callback=on_frame) + + + cortex.run(Viewer().run()) + ``` + +## Project status + +Cortex targets single-host process graphs today. See [design-review.md](design-review.md) +and [critique.md](critique.md) for an honest account of current limits and the +roadmap toward multi-host robotics use. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..76a14fb --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,142 @@ +site_name: Cortex +site_description: Lightweight Python pub/sub over ZeroMQ, for robotics and beyond. +site_url: https://sudoRicheek.github.io/cortex/ +repo_url: https://github.com/sudoRicheek/cortex +repo_name: sudoRicheek/cortex +edit_uri: edit/main/docs/ + +docs_dir: . +site_dir: site +exclude_docs: | + mkdocs.yml + gen_ref_pages.py + site/ + +theme: + name: material + features: + - navigation.tabs + - navigation.sections + - navigation.indexes + - navigation.top + - navigation.footer + - content.code.copy + - content.code.annotate + - content.tabs.link + - search.suggest + - search.highlight + - toc.follow + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + icon: + repo: fontawesome/brands/github + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - def_list + - footnotes + - tables + - toc: + permalink: true + permalink_title: Anchor link + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.snippets + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.emoji: + emoji_index: !!python/name:zensical.extensions.emoji.twemoji + emoji_generator: !!python/name:zensical.extensions.emoji.to_svg + +plugins: + - search + - section-index + - literate-nav: + nav_file: SUMMARY.md + - gen-files: + scripts: + - gen_ref_pages.py + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [../src] + options: + docstring_style: google + show_source: true + show_root_heading: true + show_root_full_path: false + show_object_full_path: false + show_category_heading: true + members_order: source + show_signature_annotations: true + separate_signature: true + show_if_no_docstring: false + heading_level: 2 + filters: + - "!^_" + - "!^__" + +nav: + - Home: index.md + - Getting started: + - getting-started/installation.md + - getting-started/quickstart.md + - getting-started/discovery-daemon.md + - Concepts: + - concepts/architecture.md + - concepts/message-wire-format.md + - concepts/fingerprinting.md + - concepts/discovery-protocol.md + - concepts/transport-and-qos.md + - concepts/async-execution-model.md + - Components: + - components/messages.md + - components/discovery.md + - components/publisher-subscriber.md + - components/node-and-executors.md + - components/serialization.md + - Tutorials: + - tutorials/custom-messages.md + - tutorials/multi-node-system.md + - tutorials/numpy-and-images.md + - tutorials/pytorch-tensors.md + - Guides: + - guides/performance-tuning.md + - guides/benchmarks.md + - guides/debugging.md + - Design notes: + - design-review.md + - critique.md + - API reference: reference/ + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/sudoRicheek/cortex diff --git a/docs/optimizations.md b/docs/optimizations.md deleted file mode 100644 index f841522..0000000 --- a/docs/optimizations.md +++ /dev/null @@ -1,106 +0,0 @@ -# Cortex Optimization Notes - -This document covers the optimizations implemented in March 2026 to reduce serialization and transport overhead in Cortex without introducing shared memory. - -## Goals - -- Reduce Python-side copying for large NumPy payloads. -- Reduce per-message metadata overhead. -- Preserve the public `Message`, `Publisher`, and `Subscriber` APIs. -- Keep the discovery plane unchanged. - -## Implemented Changes - -### 1. Frame-aware transport for large payloads - -Before this change, a message was serialized into a single Python `bytes` blob and sent as one ZMQ frame. For array-heavy robotics traffic, that meant: - -- building a large Python bytes object in user space, -- copying array contents into that bytes object, -- then handing the merged blob to ZeroMQ for another copy into its own buffers. - -The transport path now uses message frames: - -- frame 1: fixed-size message header, -- frame 2: packed metadata for field values, -- frame 3+: raw array or tensor buffers. - -This matters because top-level and nested arrays no longer need to be flattened into the metadata blob. The sender can hand ZeroMQ a view of the contiguous array buffer directly. - -### 2. Schema-ordered message payloads - -`Message.to_bytes()` previously serialized message fields as a dictionary keyed by field name. That repeated field names on every message and forced more structure building on both encode and decode. - -Messages now serialize field values in dataclass declaration order. The receiver reconstructs the message using the cached field order for that message class. - -Benefits: - -- less metadata per message, -- fewer Python objects created on the hot path, -- no repeated field-name encoding on every publish. - -### 3. Cached message schema metadata - -Message classes now cache their ordered field names. This is a small optimization by itself, but it matters because the send path is otherwise rebuilding dataclass metadata every time. - -### 4. Msgpack extension types for inline array/tensor support - -The generic serialization helpers no longer recursively wrap every nested value with custom type headers. Instead, they lean on msgpack directly and use extension hooks for: - -- NumPy arrays, -- PyTorch tensors. - -That removes a large amount of Python recursion and custom length-prefix bookkeeping for nested dict/list payloads. - -### 5. Zero-copy NumPy decode by default - -`deserialize_numpy()` used to end with `arr.copy()`, which guaranteed an extra full-memory copy on every decode. - -That copy is now removed by default. The function returns a NumPy array backed by the source buffer unless the caller explicitly asks for `copy=True`. - -This is a meaningful win for image, lidar, and tensor-like workloads where the extra copy was pure overhead. - -### 6. Subscriber receive path now uses non-copying ZMQ frame access - -The subscriber now receives multipart data with `copy=False` and reconstructs arrays from the underlying frame buffers. This lines up with the out-of-band transport and prevents unnecessary Python-side copies before NumPy even sees the data. - -## Expected Impact - -The main expected wins are: - -- lower CPU time for large array messages, -- lower allocator pressure, -- lower tail latency under sustained throughput, -- better bandwidth utilization for image and point-cloud payloads. - -The biggest gains should show up on: - -- `ArrayMessage`, -- `ImageMessage`, -- point cloud style messages with multiple arrays, -- nested dictionary payloads that include arrays. - -The smallest gains will be on tiny primitive-only messages, where ZeroMQ scheduling and Python coroutine overhead dominate. - -## Tradeoffs Introduced - -- Some decoded NumPy arrays now reference the incoming transport buffer instead of owning their own copy. That is faster, but it means downstream code should copy only when it truly needs ownership or mutability guarantees. -- The frame transport path is more complex than the original single-blob path. That complexity is justified for robotics-sized payloads, but it does raise maintenance cost. - -## What Was Deliberately Not Done - -- No shared memory transport. -- No compression. -- No alternate wire format such as FlatBuffers, Cap'n Proto, or Protobuf. -- No change to the discovery architecture. -- No batching layer above PUB/SUB. - -## Recommended Next Steps - -If more performance is needed, the next high-value steps are: - -1. Add benchmark coverage that measures `to_bytes()` versus `to_frames()` by payload type and size. -2. Split control-plane and data-plane tuning explicitly, including socket options such as HWM, `IMMEDIATE`, and publish/drop policy. -3. Introduce explicit ownership semantics for received arrays so users can choose `borrowed` versus `owned` decode behavior. -4. Revisit the message format for multi-array messages to avoid repeated per-array metadata when schema is fixed. -5. Only after those steps, consider shared memory for very large colocated pipelines. \ No newline at end of file diff --git a/docs/site/404.html b/docs/site/404.html new file mode 100644 index 0000000..ad213ea --- /dev/null +++ b/docs/site/404.html @@ -0,0 +1,1599 @@ + + + + + + + + + + + + + + + + + + + + + + + + Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + +
+ + + + +
+
+
+ + + +
+ +
+ +

404 - Not found

+ +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/assets/images/favicon.png b/docs/site/assets/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..1cf13b9f9d978896599290a74f77d5dbe7d1655c GIT binary patch literal 1870 zcmV-U2eJ5xP)Gc)JR9QMau)O=X#!i9;T z37kk-upj^(fsR36MHs_+1RCI)NNu9}lD0S{B^g8PN?Ww(5|~L#Ng*g{WsqleV}|#l zz8@ri&cTzw_h33bHI+12+kK6WN$h#n5cD8OQt`5kw6p~9H3()bUQ8OS4Q4HTQ=1Ol z_JAocz`fLbT2^{`8n~UAo=#AUOf=SOq4pYkt;XbC&f#7lb$*7=$na!mWCQ`dBQsO0 zLFBSPj*N?#u5&pf2t4XjEGH|=pPQ8xh7tpx;US5Cx_Ju;!O`ya-yF`)b%TEt5>eP1ZX~}sjjA%FJF?h7cX8=b!DZl<6%Cv z*G0uvvU+vmnpLZ2paivG-(cd*y3$hCIcsZcYOGh{$&)A6*XX&kXZd3G8m)G$Zz-LV z^GF3VAW^Mdv!)4OM8EgqRiz~*Cji;uzl2uC9^=8I84vNp;ltJ|q-*uQwGp2ma6cY7 z;`%`!9UXO@fr&Ebapfs34OmS9^u6$)bJxrucutf>`dKPKT%%*d3XlFVKunp9 zasduxjrjs>f8V=D|J=XNZp;_Zy^WgQ$9WDjgY=z@stwiEBm9u5*|34&1Na8BMjjgf3+SHcr`5~>oz1Y?SW^=K z^bTyO6>Gar#P_W2gEMwq)ot3; zREHn~U&Dp0l6YT0&k-wLwYjb?5zGK`W6S2v+K>AM(95m2C20L|3m~rN8dprPr@t)5lsk9Hu*W z?pS990s;Ez=+Rj{x7p``4>+c0G5^pYnB1^!TL=(?HLHZ+HicG{~4F1d^5Awl_2!1jICM-!9eoLhbbT^;yHcefyTAaqRcY zmuctDopPT!%k+}x%lZRKnzykr2}}XfG_ne?nRQO~?%hkzo;@RN{P6o`&mMUWBYMTe z6i8ChtjX&gXl`nvrU>jah)2iNM%JdjqoaeaU%yVn!^70x-flljp6Q5tK}5}&X8&&G zX3fpb3E(!rH=zVI_9Gjl45w@{(ITqngWFe7@9{mX;tO25Z_8 zQHEpI+FkTU#4xu>RkN>b3Tnc3UpWzPXWm#o55GKF09j^Mh~)K7{QqbO_~(@CVq! zS<8954|P8mXN2MRs86xZ&Q4EfM@JB94b=(YGuk)s&^jiSF=t3*oNK3`rD{H`yQ?d; ztE=laAUoZx5?RC8*WKOj`%LXEkgDd>&^Q4M^z`%u0rg-It=hLCVsq!Z%^6eB-OvOT zFZ28TN&cRmgU}Elrnk43)!>Z1FCPL2K$7}gwzIc48NX}#!A1BpJP?#v5wkNprhV** z?Cpalt1oH&{r!o3eSKc&ap)iz2BTn_VV`4>9M^b3;(YY}4>#ML6{~(4mH+?%07*qo IM6N<$f(jP3KmY&$ literal 0 HcmV?d00001 diff --git a/docs/site/assets/javascripts/LICENSE b/docs/site/assets/javascripts/LICENSE new file mode 100644 index 0000000..baab16b --- /dev/null +++ b/docs/site/assets/javascripts/LICENSE @@ -0,0 +1,29 @@ +------------------------------------------------------------------------------- +Third-Party licenses +------------------------------------------------------------------------------- + +Package: clipboard@2.0.11 +License: MIT +Copyright: Zeno Rocha + +------------------------------------------------------------------------------- + +Package: escape-html@1.0.3 +License: MIT +Copyright: 2012-2013 TJ Holowaychuk + 2015 Andreas Lubbe + 2015 Tiancheng "Timothy" Gu + +------------------------------------------------------------------------------- + +Package: focus-visible@5.2.1 +License: W3C +Copyright: WICG + +------------------------------------------------------------------------------- + +Package: rxjs@7.8.2 +License: Apache-2.0 +Copyright: 2015-2018 Google, Inc., + 2015-2018 Netflix, Inc., + 2015-2018 Microsoft Corp. and contributors diff --git a/docs/site/assets/javascripts/bundle.63456bd9.min.js b/docs/site/assets/javascripts/bundle.63456bd9.min.js new file mode 100644 index 0000000..38f0ece --- /dev/null +++ b/docs/site/assets/javascripts/bundle.63456bd9.min.js @@ -0,0 +1,3 @@ +"use strict";(()=>{var xc=Object.create;var kn=Object.defineProperty,wc=Object.defineProperties,Ec=Object.getOwnPropertyDescriptor,Tc=Object.getOwnPropertyDescriptors,Sc=Object.getOwnPropertyNames,Dr=Object.getOwnPropertySymbols,Oc=Object.getPrototypeOf,An=Object.prototype.hasOwnProperty,Fo=Object.prototype.propertyIsEnumerable;var jo=(e,t,r)=>t in e?kn(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,H=(e,t)=>{for(var r in t||(t={}))An.call(t,r)&&jo(e,r,t[r]);if(Dr)for(var r of Dr(t))Fo.call(t,r)&&jo(e,r,t[r]);return e},He=(e,t)=>wc(e,Tc(t));var gr=(e,t)=>{var r={};for(var n in e)An.call(e,n)&&t.indexOf(n)<0&&(r[n]=e[n]);if(e!=null&&Dr)for(var n of Dr(e))t.indexOf(n)<0&&Fo.call(e,n)&&(r[n]=e[n]);return r};var Cn=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Lc=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of Sc(t))!An.call(e,o)&&o!==r&&kn(e,o,{get:()=>t[o],enumerable:!(n=Ec(t,o))||n.enumerable});return e};var _r=(e,t,r)=>(r=e!=null?xc(Oc(e)):{},Lc(t||!e||!e.__esModule?kn(r,"default",{value:e,enumerable:!0}):r,e));var Uo=(e,t,r)=>new Promise((n,o)=>{var i=c=>{try{s(r.next(c))}catch(l){o(l)}},a=c=>{try{s(r.throw(c))}catch(l){o(l)}},s=c=>c.done?n(c.value):Promise.resolve(c.value).then(i,a);s((r=r.apply(e,t)).next())});var Do=Cn((Hn,No)=>{(function(e,t){typeof Hn=="object"&&typeof No!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(Hn,(function(){"use strict";function e(r){var n=!0,o=!1,i=null,a={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function s(_){return!!(_&&_!==document&&_.nodeName!=="HTML"&&_.nodeName!=="BODY"&&"classList"in _&&"contains"in _.classList)}function c(_){var de=_.type,be=_.tagName;return!!(be==="INPUT"&&a[de]&&!_.readOnly||be==="TEXTAREA"&&!_.readOnly||_.isContentEditable)}function l(_){_.classList.contains("focus-visible")||(_.classList.add("focus-visible"),_.setAttribute("data-focus-visible-added",""))}function u(_){_.hasAttribute("data-focus-visible-added")&&(_.classList.remove("focus-visible"),_.removeAttribute("data-focus-visible-added"))}function p(_){_.metaKey||_.altKey||_.ctrlKey||(s(r.activeElement)&&l(r.activeElement),n=!0)}function d(_){n=!1}function m(_){s(_.target)&&(n||c(_.target))&&l(_.target)}function h(_){s(_.target)&&(_.target.classList.contains("focus-visible")||_.target.hasAttribute("data-focus-visible-added"))&&(o=!0,window.clearTimeout(i),i=window.setTimeout(function(){o=!1},100),u(_.target))}function v(_){document.visibilityState==="hidden"&&(o&&(n=!0),x())}function x(){document.addEventListener("mousemove",E),document.addEventListener("mousedown",E),document.addEventListener("mouseup",E),document.addEventListener("pointermove",E),document.addEventListener("pointerdown",E),document.addEventListener("pointerup",E),document.addEventListener("touchmove",E),document.addEventListener("touchstart",E),document.addEventListener("touchend",E)}function w(){document.removeEventListener("mousemove",E),document.removeEventListener("mousedown",E),document.removeEventListener("mouseup",E),document.removeEventListener("pointermove",E),document.removeEventListener("pointerdown",E),document.removeEventListener("pointerup",E),document.removeEventListener("touchmove",E),document.removeEventListener("touchstart",E),document.removeEventListener("touchend",E)}function E(_){_.target.nodeName&&_.target.nodeName.toLowerCase()==="html"||(n=!1,w())}document.addEventListener("keydown",p,!0),document.addEventListener("mousedown",d,!0),document.addEventListener("pointerdown",d,!0),document.addEventListener("touchstart",d,!0),document.addEventListener("visibilitychange",v,!0),x(),r.addEventListener("focus",m,!0),r.addEventListener("blur",h,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)}))});var So=Cn((M0,vs)=>{"use strict";var Gu=/["'&<>]/;vs.exports=Ju;function Ju(e){var t=""+e,r=Gu.exec(t);if(!r)return t;var n,o="",i=0,a=0;for(i=r.index;i{(function(t,r){typeof jr=="object"&&typeof Lo=="object"?Lo.exports=r():typeof define=="function"&&define.amd?define([],r):typeof jr=="object"?jr.ClipboardJS=r():t.ClipboardJS=r()})(jr,function(){return(function(){var e={686:(function(n,o,i){"use strict";i.d(o,{default:function(){return vr}});var a=i(279),s=i.n(a),c=i(370),l=i.n(c),u=i(817),p=i.n(u);function d(B){try{return document.execCommand(B)}catch(C){return!1}}var m=function(C){var k=p()(C);return d("cut"),k},h=m;function v(B){var C=document.documentElement.getAttribute("dir")==="rtl",k=document.createElement("textarea");k.style.fontSize="12pt",k.style.border="0",k.style.padding="0",k.style.margin="0",k.style.position="absolute",k.style[C?"right":"left"]="-9999px";var D=window.pageYOffset||document.documentElement.scrollTop;return k.style.top="".concat(D,"px"),k.setAttribute("readonly",""),k.value=B,k}var x=function(C,k){var D=v(C);k.container.appendChild(D);var W=p()(D);return d("copy"),D.remove(),W},w=function(C){var k=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},D="";return typeof C=="string"?D=x(C,k):C instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(C==null?void 0:C.type)?D=x(C.value,k):(D=p()(C),d("copy")),D},E=w;function _(B){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?_=function(k){return typeof k}:_=function(k){return k&&typeof Symbol=="function"&&k.constructor===Symbol&&k!==Symbol.prototype?"symbol":typeof k},_(B)}var de=function(){var C=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},k=C.action,D=k===void 0?"copy":k,W=C.container,Z=C.target,We=C.text;if(D!=="copy"&&D!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(Z!==void 0)if(Z&&_(Z)==="object"&&Z.nodeType===1){if(D==="copy"&&Z.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(D==="cut"&&(Z.hasAttribute("readonly")||Z.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(We)return E(We,{container:W});if(Z)return D==="cut"?h(Z):E(Z,{container:W})},be=de;function M(B){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?M=function(k){return typeof k}:M=function(k){return k&&typeof Symbol=="function"&&k.constructor===Symbol&&k!==Symbol.prototype?"symbol":typeof k},M(B)}function O(B,C){if(!(B instanceof C))throw new TypeError("Cannot call a class as a function")}function N(B,C){for(var k=0;k0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof W.action=="function"?W.action:this.defaultAction,this.target=typeof W.target=="function"?W.target:this.defaultTarget,this.text=typeof W.text=="function"?W.text:this.defaultText,this.container=M(W.container)==="object"?W.container:document.body}},{key:"listenClick",value:function(W){var Z=this;this.listener=l()(W,"click",function(We){return Z.onClick(We)})}},{key:"onClick",value:function(W){var Z=W.delegateTarget||W.currentTarget,We=this.action(Z)||"copy",Gt=be({action:We,container:this.container,target:this.target(Z),text:this.text(Z)});this.emit(Gt?"success":"error",{action:We,text:Gt,trigger:Z,clearSelection:function(){Z&&Z.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(W){return Yt("action",W)}},{key:"defaultTarget",value:function(W){var Z=Yt("target",W);if(Z)return document.querySelector(Z)}},{key:"defaultText",value:function(W){return Yt("text",W)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(W){var Z=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return E(W,Z)}},{key:"cut",value:function(W){return h(W)}},{key:"isSupported",value:function(){var W=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],Z=typeof W=="string"?[W]:W,We=!!document.queryCommandSupported;return Z.forEach(function(Gt){We=We&&!!document.queryCommandSupported(Gt)}),We}}]),k})(s()),vr=Mt}),828:(function(n){var o=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function a(s,c){for(;s&&s.nodeType!==o;){if(typeof s.matches=="function"&&s.matches(c))return s;s=s.parentNode}}n.exports=a}),438:(function(n,o,i){var a=i(828);function s(u,p,d,m,h){var v=l.apply(this,arguments);return u.addEventListener(d,v,h),{destroy:function(){u.removeEventListener(d,v,h)}}}function c(u,p,d,m,h){return typeof u.addEventListener=="function"?s.apply(null,arguments):typeof d=="function"?s.bind(null,document).apply(null,arguments):(typeof u=="string"&&(u=document.querySelectorAll(u)),Array.prototype.map.call(u,function(v){return s(v,p,d,m,h)}))}function l(u,p,d,m){return function(h){h.delegateTarget=a(h.target,p),h.delegateTarget&&m.call(u,h)}}n.exports=c}),879:(function(n,o){o.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},o.nodeList=function(i){var a=Object.prototype.toString.call(i);return i!==void 0&&(a==="[object NodeList]"||a==="[object HTMLCollection]")&&"length"in i&&(i.length===0||o.node(i[0]))},o.string=function(i){return typeof i=="string"||i instanceof String},o.fn=function(i){var a=Object.prototype.toString.call(i);return a==="[object Function]"}}),370:(function(n,o,i){var a=i(879),s=i(438);function c(d,m,h){if(!d&&!m&&!h)throw new Error("Missing required arguments");if(!a.string(m))throw new TypeError("Second argument must be a String");if(!a.fn(h))throw new TypeError("Third argument must be a Function");if(a.node(d))return l(d,m,h);if(a.nodeList(d))return u(d,m,h);if(a.string(d))return p(d,m,h);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function l(d,m,h){return d.addEventListener(m,h),{destroy:function(){d.removeEventListener(m,h)}}}function u(d,m,h){return Array.prototype.forEach.call(d,function(v){v.addEventListener(m,h)}),{destroy:function(){Array.prototype.forEach.call(d,function(v){v.removeEventListener(m,h)})}}}function p(d,m,h){return s(document.body,d,m,h)}n.exports=c}),817:(function(n){function o(i){var a;if(i.nodeName==="SELECT")i.focus(),a=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var s=i.hasAttribute("readonly");s||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),s||i.removeAttribute("readonly"),a=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var c=window.getSelection(),l=document.createRange();l.selectNodeContents(i),c.removeAllRanges(),c.addRange(l),a=c.toString()}return a}n.exports=o}),279:(function(n){function o(){}o.prototype={on:function(i,a,s){var c=this.e||(this.e={});return(c[i]||(c[i]=[])).push({fn:a,ctx:s}),this},once:function(i,a,s){var c=this;function l(){c.off(i,l),a.apply(s,arguments)}return l._=a,this.on(i,l,s)},emit:function(i){var a=[].slice.call(arguments,1),s=((this.e||(this.e={}))[i]||[]).slice(),c=0,l=s.length;for(c;c0&&i[i.length-1])&&(l[0]===6||l[0]===2)){r=0;continue}if(l[0]===3&&(!i||l[1]>i[0]&&l[1]=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function te(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var n=r.call(e),o,i=[],a;try{for(;(t===void 0||t-- >0)&&!(o=n.next()).done;)i.push(o.value)}catch(s){a={error:s}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(a)throw a.error}}return i}function ne(e,t,r){if(r||arguments.length===2)for(var n=0,o=t.length,i;n1||c(m,v)})},h&&(o[m]=h(o[m])))}function c(m,h){try{l(n[m](h))}catch(v){d(i[0][3],v)}}function l(m){m.value instanceof kt?Promise.resolve(m.value.v).then(u,p):d(i[0][2],m)}function u(m){c("next",m)}function p(m){c("throw",m)}function d(m,h){m(h),i.shift(),i.length&&c(i[0][0],i[0][1])}}function zo(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof $e=="function"?$e(e):e[Symbol.iterator](),r={},n("next"),n("throw"),n("return"),r[Symbol.asyncIterator]=function(){return this},r);function n(i){r[i]=e[i]&&function(a){return new Promise(function(s,c){a=e[i](a),o(s,c,a.done,a.value)})}}function o(i,a,s,c){Promise.resolve(c).then(function(l){i({value:l,done:s})},a)}}function F(e){return typeof e=="function"}function Jt(e){var t=function(n){Error.call(n),n.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var Vr=Jt(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(n,o){return o+1+") "+n.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function ct(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var rt=(function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,n,o,i;if(!this.closed){this.closed=!0;var a=this._parentage;if(a)if(this._parentage=null,Array.isArray(a))try{for(var s=$e(a),c=s.next();!c.done;c=s.next()){var l=c.value;l.remove(this)}}catch(v){t={error:v}}finally{try{c&&!c.done&&(r=s.return)&&r.call(s)}finally{if(t)throw t.error}}else a.remove(this);var u=this.initialTeardown;if(F(u))try{u()}catch(v){i=v instanceof Vr?v.errors:[v]}var p=this._finalizers;if(p){this._finalizers=null;try{for(var d=$e(p),m=d.next();!m.done;m=d.next()){var h=m.value;try{qo(h)}catch(v){i=i!=null?i:[],v instanceof Vr?i=ne(ne([],te(i)),te(v.errors)):i.push(v)}}}catch(v){n={error:v}}finally{try{m&&!m.done&&(o=d.return)&&o.call(d)}finally{if(n)throw n.error}}}if(i)throw new Vr(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)qo(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&ct(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&ct(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e})();var Pn=rt.EMPTY;function zr(e){return e instanceof rt||e&&"closed"in e&&F(e.remove)&&F(e.add)&&F(e.unsubscribe)}function qo(e){F(e)?e():e.unsubscribe()}var Je={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var Xt={setTimeout:function(e,t){for(var r=[],n=2;n0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var n=this,o=this,i=o.hasError,a=o.isStopped,s=o.observers;return i||a?Pn:(this.currentObservers=null,s.push(r),new rt(function(){n.currentObservers=null,ct(s,r)}))},t.prototype._checkFinalizedStatuses=function(r){var n=this,o=n.hasError,i=n.thrownError,a=n.isStopped;o?r.error(i):a&&r.complete()},t.prototype.asObservable=function(){var r=new U;return r.source=this,r},t.create=function(r,n){return new Qo(r,n)},t})(U);var Qo=(function(e){ue(t,e);function t(r,n){var o=e.call(this)||this;return o.destination=r,o.source=n,o}return t.prototype.next=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.next)===null||o===void 0||o.call(n,r)},t.prototype.error=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.error)===null||o===void 0||o.call(n,r)},t.prototype.complete=function(){var r,n;(n=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||n===void 0||n.call(r)},t.prototype._subscribe=function(r){var n,o;return(o=(n=this.source)===null||n===void 0?void 0:n.subscribe(r))!==null&&o!==void 0?o:Pn},t})(I);var Un=(function(e){ue(t,e);function t(r){var n=e.call(this)||this;return n._value=r,n}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(r){var n=e.prototype._subscribe.call(this,r);return!n.closed&&r.next(this._value),n},t.prototype.getValue=function(){var r=this,n=r.hasError,o=r.thrownError,i=r._value;if(n)throw o;return this._throwIfClosed(),i},t.prototype.next=function(r){e.prototype.next.call(this,this._value=r)},t})(I);var xr={now:function(){return(xr.delegate||Date).now()},delegate:void 0};var wr=(function(e){ue(t,e);function t(r,n,o){r===void 0&&(r=1/0),n===void 0&&(n=1/0),o===void 0&&(o=xr);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=n,i._timestampProvider=o,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=n===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,n),i}return t.prototype.next=function(r){var n=this,o=n.isStopped,i=n._buffer,a=n._infiniteTimeWindow,s=n._timestampProvider,c=n._windowTime;o||(i.push(r),!a&&i.push(s.now()+c)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var n=this._innerSubscribe(r),o=this,i=o._infiniteTimeWindow,a=o._buffer,s=a.slice(),c=0;c0?e.prototype.schedule.call(this,r,n):(this.delay=n,this.state=r,this.scheduler.flush(this),this)},t.prototype.execute=function(r,n){return n>0||this.closed?e.prototype.execute.call(this,r,n):this._execute(r,n)},t.prototype.requestAsyncId=function(r,n,o){return o===void 0&&(o=0),o!=null&&o>0||o==null&&this.delay>0?e.prototype.requestAsyncId.call(this,r,n,o):(r.flush(this),0)},t})(tr);var ri=(function(e){ue(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t})(rr);var Wn=new ri(ti);var ni=(function(e){ue(t,e);function t(r,n){var o=e.call(this,r,n)||this;return o.scheduler=r,o.work=n,o}return t.prototype.requestAsyncId=function(r,n,o){return o===void 0&&(o=0),o!==null&&o>0?e.prototype.requestAsyncId.call(this,r,n,o):(r.actions.push(this),r._scheduled||(r._scheduled=er.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,n,o){var i;if(o===void 0&&(o=0),o!=null?o>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,n,o);var a=r.actions;n!=null&&n===r._scheduled&&((i=a[a.length-1])===null||i===void 0?void 0:i.id)!==n&&(er.cancelAnimationFrame(n),r._scheduled=void 0)},t})(tr);var oi=(function(e){ue(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var n;r?n=r.id:(n=this._scheduled,this._scheduled=void 0);var o=this.actions,i;r=r||o.shift();do if(i=r.execute(r.state,r.delay))break;while((r=o[0])&&r.id===n&&o.shift());if(this._active=!1,i){for(;(r=o[0])&&r.id===n&&o.shift();)r.unsubscribe();throw i}},t})(rr);var je=new oi(ni);var y=new U(function(e){return e.complete()});function Br(e){return e&&F(e.schedule)}function Vn(e){return e[e.length-1]}function _t(e){return F(Vn(e))?e.pop():void 0}function qe(e){return Br(Vn(e))?e.pop():void 0}function Yr(e,t){return typeof Vn(e)=="number"?e.pop():t}var nr=(function(e){return e&&typeof e.length=="number"&&typeof e!="function"});function Gr(e){return F(e==null?void 0:e.then)}function Jr(e){return F(e[Qt])}function Xr(e){return Symbol.asyncIterator&&F(e==null?void 0:e[Symbol.asyncIterator])}function Zr(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function Rc(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var Qr=Rc();function en(e){return F(e==null?void 0:e[Qr])}function tn(e){return Vo(this,arguments,function(){var r,n,o,i;return Wr(this,function(a){switch(a.label){case 0:r=e.getReader(),a.label=1;case 1:a.trys.push([1,,9,10]),a.label=2;case 2:return[4,kt(r.read())];case 3:return n=a.sent(),o=n.value,i=n.done,i?[4,kt(void 0)]:[3,5];case 4:return[2,a.sent()];case 5:return[4,kt(o)];case 6:return[4,a.sent()];case 7:return a.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function rn(e){return F(e==null?void 0:e.getReader)}function q(e){if(e instanceof U)return e;if(e!=null){if(Jr(e))return jc(e);if(nr(e))return Fc(e);if(Gr(e))return Uc(e);if(Xr(e))return ii(e);if(en(e))return Nc(e);if(rn(e))return Dc(e)}throw Zr(e)}function jc(e){return new U(function(t){var r=e[Qt]();if(F(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function Fc(e){return new U(function(t){for(var r=0;r=2;return function(n){return n.pipe(e?L(function(o,i){return e(o,i,n)}):Le,Me(1),r?ot(t):wi(function(){return new on}))}}function Gn(e){return e<=0?function(){return y}:S(function(t,r){var n=[];t.subscribe(T(r,function(o){n.push(o),e=2,!0))}function xe(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new I}:t,n=e.resetOnError,o=n===void 0?!0:n,i=e.resetOnComplete,a=i===void 0?!0:i,s=e.resetOnRefCountZero,c=s===void 0?!0:s;return function(l){var u,p,d,m=0,h=!1,v=!1,x=function(){p==null||p.unsubscribe(),p=void 0},w=function(){x(),u=d=void 0,h=v=!1},E=function(){var _=u;w(),_==null||_.unsubscribe()};return S(function(_,de){m++,!v&&!h&&x();var be=d=d!=null?d:r();de.add(function(){m--,m===0&&!v&&!h&&(p=Jn(E,c))}),be.subscribe(de),!u&&m>0&&(u=new Ct({next:function(M){return be.next(M)},error:function(M){v=!0,x(),p=Jn(w,o,M),be.error(M)},complete:function(){h=!0,x(),p=Jn(w,a),be.complete()}}),q(_).subscribe(u))})(l)}}function Jn(e,t){for(var r=[],n=2;ne.next(document)),e}function P(e,t=document){return Array.from(t.querySelectorAll(e))}function G(e,t=document){let r=we(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function we(e,t=document){return t.querySelector(e)||void 0}function xt(){var e,t,r,n;return(n=(r=(t=(e=document.activeElement)==null?void 0:e.shadowRoot)==null?void 0:t.activeElement)!=null?r:document.activeElement)!=null?n:void 0}var il=R(b(document.body,"focusin"),b(document.body,"focusout")).pipe(Be(1),J(void 0),f(()=>xt()||document.body),se(1));function ir(e){return il.pipe(f(t=>e.contains(t)),ie())}function Ft(e,t){let{matches:r}=matchMedia("(hover)");return j(()=>(r?R(b(e,"mouseenter").pipe(f(()=>!0)),b(e,"mouseleave").pipe(f(()=>!1))):R(b(e,"touchstart").pipe(f(()=>!0)),b(e,"touchend").pipe(f(()=>!1)),b(e,"touchcancel").pipe(f(()=>!1)))).pipe(t?Tr(o=>Ve(+!o*t)):Le,J(!0,e.matches(":hover"))))}function Oi(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)Oi(e,r)}function A(e,t,...r){let n=document.createElement(e);if(t)for(let o of Object.keys(t))typeof t[o]!="undefined"&&(typeof t[o]!="boolean"?n.setAttribute(o,t[o]):n.setAttribute(o,""));for(let o of r)Oi(n,o);return n}function Li(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function ar(e){let t=A("script",{src:e});return j(()=>(document.head.appendChild(t),R(b(t,"load"),b(t,"error").pipe(g(()=>zn(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(f(()=>{}),V(()=>document.head.removeChild(t)),Me(1))))}var Mi=new I,al=j(()=>typeof ResizeObserver=="undefined"?ar("https://unpkg.com/resize-observer-polyfill"):Y(void 0)).pipe(f(()=>new ResizeObserver(e=>e.forEach(t=>Mi.next(t)))),g(e=>R(Ke,Y(e)).pipe(V(()=>e.disconnect()))),se(1));function Ae(e){return{width:e.offsetWidth,height:e.offsetHeight}}function Re(e){let t=e;for(;t.clientWidth===0&&t.parentElement;)t=t.parentElement;return al.pipe($(r=>r.observe(t)),g(r=>Mi.pipe(L(n=>n.target===t),V(()=>r.unobserve(t)))),f(()=>Ae(e)),J(Ae(e)))}function Mr(e){return{width:e.scrollWidth,height:e.scrollHeight}}function ki(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}function Ai(e){let t=[],r=e.parentElement;for(;r;)(e.clientWidth>r.clientWidth||e.clientHeight>r.clientHeight)&&t.push(r),r=(e=r).parentElement;return t.length===0&&t.push(document.documentElement),t}function wt(e){return{x:e.offsetLeft,y:e.offsetTop}}function Ci(e){let t=e.getBoundingClientRect();return{x:t.x+window.scrollX,y:t.y+window.scrollY}}function Hi(e){return R(b(window,"load"),b(window,"resize")).pipe(Xe(0,je),f(()=>wt(e)),J(wt(e)))}function ln(e){return{x:e.scrollLeft,y:e.scrollTop}}function Ut(e){return R(b(e,"scroll"),b(window,"scroll"),b(window,"resize")).pipe(Xe(0,je),f(()=>ln(e)),J(ln(e)))}var $i=new I,sl=j(()=>Y(new IntersectionObserver(e=>{for(let t of e)$i.next(t)},{threshold:0}))).pipe(g(e=>R(Ke,Y(e)).pipe(V(()=>e.disconnect()))),se(1));function Et(e){return sl.pipe($(t=>t.observe(e)),g(t=>$i.pipe(L(({target:r})=>r===e),V(()=>t.unobserve(e)),f(({isIntersecting:r})=>r))))}var cl=Object.create,la=Object.defineProperty,ll=Object.getOwnPropertyDescriptor,ul=Object.getOwnPropertyNames,pl=Object.getPrototypeOf,fl=Object.prototype.hasOwnProperty,ml=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),dl=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of ul(t))!fl.call(e,o)&&o!==r&&la(e,o,{get:()=>t[o],enumerable:!(n=ll(t,o))||n.enumerable});return e},hl=(e,t,r)=>(r=e!=null?cl(pl(e)):{},dl(t||!e||!e.__esModule?la(r,"default",{value:e,enumerable:!0}):r,e)),vl=ml((e,t)=>{var r="Expected a function",n=NaN,o="[object Symbol]",i=/^\s+|\s+$/g,a=/^[-+]0x[0-9a-f]+$/i,s=/^0b[01]+$/i,c=/^0o[0-7]+$/i,l=parseInt,u=typeof global=="object"&&global&&global.Object===Object&&global,p=typeof self=="object"&&self&&self.Object===Object&&self,d=u||p||Function("return this")(),m=Object.prototype,h=m.toString,v=Math.max,x=Math.min,w=function(){return d.Date.now()};function E(O,N,ee){var le,ce,Ne,bt,De,st,tt=0,Yt=!1,Mt=!1,vr=!0;if(typeof O!="function")throw new TypeError(r);N=M(N)||0,_(ee)&&(Yt=!!ee.leading,Mt="maxWait"in ee,Ne=Mt?v(M(ee.maxWait)||0,N):Ne,vr="trailing"in ee?!!ee.trailing:vr);function B(Se){var gt=le,br=ce;return le=ce=void 0,tt=Se,bt=O.apply(br,gt),bt}function C(Se){return tt=Se,De=setTimeout(W,N),Yt?B(Se):bt}function k(Se){var gt=Se-st,br=Se-tt,Ro=N-gt;return Mt?x(Ro,Ne-br):Ro}function D(Se){var gt=Se-st,br=Se-tt;return st===void 0||gt>=N||gt<0||Mt&&br>=Ne}function W(){var Se=w();if(D(Se))return Z(Se);De=setTimeout(W,k(Se))}function Z(Se){return De=void 0,vr&&le?B(Se):(le=ce=void 0,bt)}function We(){De!==void 0&&clearTimeout(De),tt=0,le=st=ce=De=void 0}function Gt(){return De===void 0?bt:Z(w())}function Nr(){var Se=w(),gt=D(Se);if(le=arguments,ce=this,st=Se,gt){if(De===void 0)return C(st);if(Mt)return De=setTimeout(W,N),B(st)}return De===void 0&&(De=setTimeout(W,N)),bt}return Nr.cancel=We,Nr.flush=Gt,Nr}function _(O){var N=typeof O;return!!O&&(N=="object"||N=="function")}function de(O){return!!O&&typeof O=="object"}function be(O){return typeof O=="symbol"||de(O)&&h.call(O)==o}function M(O){if(typeof O=="number")return O;if(be(O))return n;if(_(O)){var N=typeof O.valueOf=="function"?O.valueOf():O;O=_(N)?N+"":N}if(typeof O!="string")return O===0?O:+O;O=O.replace(i,"");var ee=s.test(O);return ee||c.test(O)?l(O.slice(2),ee?2:8):a.test(O)?n:+O}t.exports=E}),yn,K,ua,pa,Nt,Pi,fa,ma,da,lo,to,ro,bl,Ar={},ha=[],gl=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,Pr=Array.isArray;function pt(e,t){for(var r in t)e[r]=t[r];return e}function uo(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function Wt(e,t,r){var n,o,i,a={};for(i in t)i=="key"?n=t[i]:i=="ref"?o=t[i]:a[i]=t[i];if(arguments.length>2&&(a.children=arguments.length>3?yn.call(arguments,2):r),typeof e=="function"&&e.defaultProps!=null)for(i in e.defaultProps)a[i]===void 0&&(a[i]=e.defaultProps[i]);return fn(e,a,n,o,null)}function fn(e,t,r,n,o){var i={type:e,props:t,key:r,ref:n,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o!=null?o:++ua,__i:-1,__u:0};return o==null&&K.vnode!=null&&K.vnode(i),i}function ft(e){return e.children}function at(e,t){this.props=e,this.context=t}function cr(e,t){if(t==null)return e.__?cr(e.__,e.__i+1):null;for(var r;ts&&Nt.sort(ma),e=Nt.shift(),s=Nt.length,e.__d&&(r=void 0,n=void 0,o=(n=(t=e).__v).__e,i=[],a=[],t.__P&&((r=pt({},n)).__v=n.__v+1,K.vnode&&K.vnode(r),po(t.__P,r,n,t.__n,t.__P.namespaceURI,32&n.__u?[o]:null,i,o!=null?o:cr(n),!!(32&n.__u),a),r.__v=n.__v,r.__.__k[r.__i]=r,_a(i,r,a),n.__e=n.__=null,r.__e!=o&&va(r)));vn.__r=0}function ba(e,t,r,n,o,i,a,s,c,l,u){var p,d,m,h,v,x,w,E=n&&n.__k||ha,_=t.length;for(c=_l(r,t,E,c,_),p=0;p<_;p++)(m=r.__k[p])!=null&&(d=m.__i==-1?Ar:E[m.__i]||Ar,m.__i=p,x=po(e,m,d,o,i,a,s,c,l,u),h=m.__e,m.ref&&d.ref!=m.ref&&(d.ref&&fo(d.ref,null,m),u.push(m.ref,m.__c||h,m)),v==null&&h!=null&&(v=h),(w=!!(4&m.__u))||d.__k===m.__k?c=ga(m,c,e,w):typeof m.type=="function"&&x!==void 0?c=x:h&&(c=h.nextSibling),m.__u&=-7);return r.__e=v,c}function _l(e,t,r,n,o){var i,a,s,c,l,u=r.length,p=u,d=0;for(e.__k=new Array(o),i=0;i0?fn(a.type,a.props,a.key,a.ref?a.ref:null,a.__v):a).__=e,a.__b=e.__b+1,s=null,(l=a.__i=yl(a,r,c,p))!=-1&&(p--,(s=r[l])&&(s.__u|=2)),s==null||s.__v==null?(l==-1&&(o>u?d--:oc?d--:d++,a.__u|=4))):e.__k[i]=null;if(p)for(i=0;i(u?1:0)){for(o=r-1,i=r+1;o>=0||i=0?o--:i++])!=null&&!(2&l.__u)&&s==l.key&&c==l.type)return a}return-1}function Ri(e,t,r){t[0]=="-"?e.setProperty(t,r!=null?r:""):e[t]=r==null?"":typeof r!="number"||gl.test(t)?r:r+"px"}function un(e,t,r,n,o){var i,a;e:if(t=="style")if(typeof r=="string")e.style.cssText=r;else{if(typeof n=="string"&&(e.style.cssText=n=""),n)for(t in n)r&&t in r||Ri(e.style,t,"");if(r)for(t in r)n&&r[t]==n[t]||Ri(e.style,t,r[t])}else if(t[0]=="o"&&t[1]=="n")i=t!=(t=t.replace(da,"$1")),a=t.toLowerCase(),t=a in e||t=="onFocusOut"||t=="onFocusIn"?a.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+i]=r,r?n?r.u=n.u:(r.u=lo,e.addEventListener(t,i?ro:to,i)):e.removeEventListener(t,i?ro:to,i);else{if(o=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=r!=null?r:"";break e}catch(s){}typeof r=="function"||(r==null||r===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&r==1?"":r))}}function ji(e){return function(t){if(this.l){var r=this.l[t.type+e];if(t.t==null)t.t=lo++;else if(t.t0?e:Pr(e)?e.map(ya):pt({},e)}function xl(e,t,r,n,o,i,a,s,c){var l,u,p,d,m,h,v,x=r.props,w=t.props,E=t.type;if(E=="svg"?o="http://www.w3.org/2000/svg":E=="math"?o="http://www.w3.org/1998/Math/MathML":o||(o="http://www.w3.org/1999/xhtml"),i!=null){for(l=0;l=r.__.length&&r.__.push({}),r.__[e]}function bn(e){return $r=1,Tl(Ta,e)}function Tl(e,t,r){var n=mo(Hr++,2);if(n.t=e,!n.__c&&(n.__=[r?r(t):Ta(void 0,t),function(s){var c=n.__N?n.__N[0]:n.__[0],l=n.t(c,s);c!==l&&(n.__N=[l,n.__[1]],n.__c.setState({}))}],n.__c=ve,!ve.__f)){var o=function(s,c,l){if(!n.__c.__H)return!0;var u=n.__c.__H.__.filter(function(d){return!!d.__c});if(u.every(function(d){return!d.__N}))return!i||i.call(this,s,c,l);var p=n.__c.props!==s;return u.forEach(function(d){if(d.__N){var m=d.__[0];d.__=d.__N,d.__N=void 0,m!==d.__[0]&&(p=!0)}}),i&&i.call(this,s,c,l)||p};ve.__f=!0;var i=ve.shouldComponentUpdate,a=ve.componentWillUpdate;ve.componentWillUpdate=function(s,c,l){if(this.__e){var u=i;i=void 0,o(s,c,l),i=u}a&&a.call(this,s,c,l)},ve.shouldComponentUpdate=o}return n.__N||n.__}function mt(e,t){var r=mo(Hr++,3);!Ee.__s&&Ea(r.__H,t)&&(r.__=e,r.u=t,ve.__H.__h.push(r))}function Vt(e){return $r=5,ur(function(){return{current:e}},[])}function ur(e,t){var r=mo(Hr++,7);return Ea(r.__H,t)&&(r.__=e(),r.__H=t,r.__h=e),r.__}function Sl(e,t){return $r=8,ur(function(){return e},t)}function Ol(){for(var e;e=wa.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(mn),e.__H.__h.forEach(oo),e.__H.__h=[]}catch(t){e.__H.__h=[],Ee.__e(t,e.__v)}}Ee.__b=function(e){ve=null,Ui&&Ui(e)},Ee.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),zi&&zi(e,t)},Ee.__r=function(e){Ni&&Ni(e),Hr=0;var t=(ve=e.__c).__H;t&&(Zn===ve?(t.__h=[],ve.__h=[],t.__.forEach(function(r){r.__N&&(r.__=r.__N),r.u=r.__N=void 0})):(t.__h.forEach(mn),t.__h.forEach(oo),t.__h=[],Hr=0)),Zn=ve},Ee.diffed=function(e){Di&&Di(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(wa.push(t)!==1&&Fi===Ee.requestAnimationFrame||((Fi=Ee.requestAnimationFrame)||Ll)(Ol)),t.__H.__.forEach(function(r){r.u&&(r.__H=r.u),r.u=void 0})),Zn=ve=null},Ee.__c=function(e,t){t.some(function(r){try{r.__h.forEach(mn),r.__h=r.__h.filter(function(n){return!n.__||oo(n)})}catch(n){t.some(function(o){o.__h&&(o.__h=[])}),t=[],Ee.__e(n,r.__v)}}),Wi&&Wi(e,t)},Ee.unmount=function(e){Vi&&Vi(e);var t,r=e.__c;r&&r.__H&&(r.__H.__.forEach(function(n){try{mn(n)}catch(o){t=o}}),r.__H=void 0,t&&Ee.__e(t,r.__v))};var qi=typeof requestAnimationFrame=="function";function Ll(e){var t,r=function(){clearTimeout(n),qi&&cancelAnimationFrame(t),setTimeout(e)},n=setTimeout(r,35);qi&&(t=requestAnimationFrame(r))}function mn(e){var t=ve,r=e.__c;typeof r=="function"&&(e.__c=void 0,r()),ve=t}function oo(e){var t=ve;e.__c=e.__(),ve=t}function Ea(e,t){return!e||e.length!==t.length||t.some(function(r,n){return r!==e[n]})}function Ta(e,t){return typeof t=="function"?t(e):t}function Ml(e,t){for(var r in t)e[r]=t[r];return e}function Ki(e,t){for(var r in e)if(r!=="__source"&&!(r in t))return!0;for(var n in t)if(n!=="__source"&&e[n]!==t[n])return!0;return!1}function Bi(e,t){this.props=e,this.context=t}(Bi.prototype=new at).isPureReactComponent=!0,Bi.prototype.shouldComponentUpdate=function(e,t){return Ki(this.props,e)||Ki(this.state,t)};var Yi=K.__b;K.__b=function(e){e.type&&e.type.__f&&e.ref&&(e.props.ref=e.ref,e.ref=null),Yi&&Yi(e)};var Yx=typeof Symbol<"u"&&Symbol.for&&Symbol.for("react.forward_ref")||3911,kl=K.__e;K.__e=function(e,t,r,n){if(e.then){for(var o,i=t;i=i.__;)if((o=i.__c)&&o.__c)return t.__e==null&&(t.__e=r.__e,t.__k=r.__k),o.__c(e,t)}kl(e,t,r,n)};var Gi=K.unmount;function Sa(e,t,r){return e&&(e.__c&&e.__c.__H&&(e.__c.__H.__.forEach(function(n){typeof n.__c=="function"&&n.__c()}),e.__c.__H=null),(e=Ml({},e)).__c!=null&&(e.__c.__P===r&&(e.__c.__P=t),e.__c.__e=!0,e.__c=null),e.__k=e.__k&&e.__k.map(function(n){return Sa(n,t,r)})),e}function Oa(e,t,r){return e&&r&&(e.__v=null,e.__k=e.__k&&e.__k.map(function(n){return Oa(n,t,r)}),e.__c&&e.__c.__P===t&&(e.__e&&r.appendChild(e.__e),e.__c.__e=!0,e.__c.__P=r)),e}function Qn(){this.__u=0,this.o=null,this.__b=null}function La(e){var t=e.__.__c;return t&&t.__a&&t.__a(e)}function pn(){this.i=null,this.l=null}K.unmount=function(e){var t=e.__c;t&&t.__R&&t.__R(),t&&32&e.__u&&(e.type=null),Gi&&Gi(e)},(Qn.prototype=new at).__c=function(e,t){var r=t.__c,n=this;n.o==null&&(n.o=[]),n.o.push(r);var o=La(n.__v),i=!1,a=function(){i||(i=!0,r.__R=null,o?o(s):s())};r.__R=a;var s=function(){if(!--n.__u){if(n.state.__a){var c=n.state.__a;n.__v.__k[0]=Oa(c,c.__c.__P,c.__c.__O)}var l;for(n.setState({__a:n.__b=null});l=n.o.pop();)l.forceUpdate()}};n.__u++||32&t.__u||n.setState({__a:n.__b=n.__v.__k[0]}),e.then(a,a)},Qn.prototype.componentWillUnmount=function(){this.o=[]},Qn.prototype.render=function(e,t){if(this.__b){if(this.__v.__k){var r=document.createElement("div"),n=this.__v.__k[0].__c;this.__v.__k[0]=Sa(this.__b,r,n.__O=n.__P)}this.__b=null}var o=t.__a&&Wt(ft,null,e.fallback);return o&&(o.__u&=-33),[Wt(ft,null,t.__a?null:e.children),o]};var Ji=function(e,t,r){if(++r[1]===r[0]&&e.l.delete(t),e.props.revealOrder&&(e.props.revealOrder[0]!=="t"||!e.l.size))for(r=e.i;r;){for(;r.length>3;)r.pop()();if(r[1]Object.freeze({get current(){return t.current}}),[])}var Nl=typeof globalThis<"u"&&typeof navigator<"u"&&typeof document<"u";function Dl(e,...t){var r;(r=e==null?void 0:e.addEventListener)==null||r.call(e,...t)}function Wl(e,...t){var r;(r=e==null?void 0:e.removeEventListener)==null||r.call(e,...t)}var Vl=(e,t)=>Object.hasOwn(e,t),zl=()=>!0,ql=()=>!1;function Kl(e=!1){let t=Vt(e),r=Sl(()=>t.current,[]);return mt(()=>(t.current=!0,()=>{t.current=!1}),[]),r}function Bl(e,...t){let r=Kl(),n=ka(t[1]),o=ur(()=>function(...i){r()&&(typeof n.current=="function"?n.current.apply(this,i):typeof n.current.handleEvent=="function"&&n.current.handleEvent.apply(this,i))},[]);mt(()=>{let i=Yl(e)?e.current:e;if(!i)return;let a=t.slice(2);return Dl(i,t[0],o,...a),()=>{Wl(i,t[0],o,...a)}},[e,t[0]])}function Yl(e){return e!==null&&typeof e=="object"&&Vl(e,"current")}var Gl=e=>typeof e=="function"?e:typeof e=="string"?t=>t.key===e:e?zl:ql,Jl=Nl?globalThis:null;function Aa(e,t,r=[],n={}){let{event:o="keydown",target:i=Jl,eventOptions:a}=n,s=ka(t),c=ur(()=>{let l=Gl(e);return function(u){l(u)&&s.current.call(this,u)}},r);Bl(i,o,c,a)}function Ca(e){var t,r,n="";if(typeof e=="string"||typeof e=="number")n+=e;else if(typeof e=="object")if(Array.isArray(e)){var o=e.length;for(t=0;t1)St--;else{for(var e,t=!1;kr!==void 0;){var r=kr;for(kr=void 0,io++;r!==void 0;){var n=r.o;if(r.o=void 0,r.f&=-3,!(8&r.f)&&Pa(r))try{r.c()}catch(o){t||(e=o,t=!0)}r=n}}if(io=0,St--,t)throw e}}function Ql(e){if(St>0)return e();St++;try{return e()}finally{xn()}}var ae=void 0;function Ha(e){var t=ae;ae=void 0;try{return e()}finally{ae=t}}var kr=void 0,St=0,io=0,gn=0;function $a(e){if(ae!==void 0){var t=e.n;if(t===void 0||t.t!==ae)return t={i:0,S:e,p:ae.s,n:void 0,t:ae,e:void 0,x:void 0,r:t},ae.s!==void 0&&(ae.s.n=t),ae.s=t,e.n=t,32&ae.f&&e.S(t),t;if(t.i===-1)return t.i=0,t.n!==void 0&&(t.n.p=t.p,t.p!==void 0&&(t.p.n=t.n),t.p=ae.s,t.n=void 0,ae.s.n=t,ae.s=t),t}}function Ce(e,t){this.v=e,this.i=0,this.n=void 0,this.t=void 0,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}Ce.prototype.brand=Zl;Ce.prototype.h=function(){return!0};Ce.prototype.S=function(e){var t=this,r=this.t;r!==e&&e.e===void 0&&(e.x=r,this.t=e,r!==void 0?r.e=e:Ha(function(){var n;(n=t.W)==null||n.call(t)}))};Ce.prototype.U=function(e){var t=this;if(this.t!==void 0){var r=e.e,n=e.x;r!==void 0&&(r.x=n,e.e=void 0),n!==void 0&&(n.e=r,e.x=void 0),e===this.t&&(this.t=n,n===void 0&&Ha(function(){var o;(o=t.Z)==null||o.call(t)}))}};Ce.prototype.subscribe=function(e){var t=this;return qt(function(){var r=t.value,n=ae;ae=void 0;try{e(r)}finally{ae=n}},{name:"sub"})};Ce.prototype.valueOf=function(){return this.value};Ce.prototype.toString=function(){return this.value+""};Ce.prototype.toJSON=function(){return this.value};Ce.prototype.peek=function(){var e=ae;ae=void 0;try{return this.value}finally{ae=e}};Object.defineProperty(Ce.prototype,"value",{get:function(){var e=$a(this);return e!==void 0&&(e.i=this.i),this.v},set:function(e){if(e!==this.v){if(io>100)throw new Error("Cycle detected");this.v=e,this.i++,gn++,St++;try{for(var t=this.t;t!==void 0;t=t.x)t.t.N()}finally{xn()}}}});function Ot(e,t){return new Ce(e,t)}function Pa(e){for(var t=e.s;t!==void 0;t=t.n)if(t.S.i!==t.i||!t.S.h()||t.S.i!==t.i)return!0;return!1}function Ia(e){for(var t=e.s;t!==void 0;t=t.n){var r=t.S.n;if(r!==void 0&&(t.r=r),t.S.n=t,t.i=-1,t.n===void 0){e.s=t;break}}}function Ra(e){for(var t=e.s,r=void 0;t!==void 0;){var n=t.p;t.i===-1?(t.S.U(t),n!==void 0&&(n.n=t.n),t.n!==void 0&&(t.n.p=n)):r=t,t.S.n=t.r,t.r!==void 0&&(t.r=void 0),t=n}e.s=r}function Kt(e,t){Ce.call(this,void 0),this.x=e,this.s=void 0,this.g=gn-1,this.f=4,this.W=t==null?void 0:t.watched,this.Z=t==null?void 0:t.unwatched,this.name=t==null?void 0:t.name}Kt.prototype=new Ce;Kt.prototype.h=function(){if(this.f&=-3,1&this.f)return!1;if((36&this.f)==32||(this.f&=-5,this.g===gn))return!0;if(this.g=gn,this.f|=1,this.i>0&&!Pa(this))return this.f&=-2,!0;var e=ae;try{Ia(this),ae=this;var t=this.x();(16&this.f||this.v!==t||this.i===0)&&(this.v=t,this.f&=-17,this.i++)}catch(r){this.v=r,this.f|=16,this.i++}return ae=e,Ra(this),this.f&=-2,!0};Kt.prototype.S=function(e){if(this.t===void 0){this.f|=36;for(var t=this.s;t!==void 0;t=t.n)t.S.S(t)}Ce.prototype.S.call(this,e)};Kt.prototype.U=function(e){if(this.t!==void 0&&(Ce.prototype.U.call(this,e),this.t===void 0)){this.f&=-33;for(var t=this.s;t!==void 0;t=t.n)t.S.U(t)}};Kt.prototype.N=function(){if(!(2&this.f)){this.f|=6;for(var e=this.t;e!==void 0;e=e.x)e.t.N()}};Object.defineProperty(Kt.prototype,"value",{get:function(){if(1&this.f)throw new Error("Cycle detected");var e=$a(this);if(this.h(),e!==void 0&&(e.i=this.i),16&this.f)throw this.v;return this.v}});function ta(e,t){return new Kt(e,t)}function ja(e){var t=e.u;if(e.u=void 0,typeof t=="function"){St++;var r=ae;ae=void 0;try{t()}catch(n){throw e.f&=-2,e.f|=8,ho(e),n}finally{ae=r,xn()}}}function ho(e){for(var t=e.s;t!==void 0;t=t.n)t.S.U(t);e.x=void 0,e.s=void 0,ja(e)}function eu(e){if(ae!==this)throw new Error("Out-of-order effect");Ra(this),ae=e,this.f&=-2,8&this.f&&ho(this),xn()}function pr(e,t){this.x=e,this.u=void 0,this.s=void 0,this.o=void 0,this.f=32,this.name=t==null?void 0:t.name}pr.prototype.c=function(){var e=this.S();try{if(8&this.f||this.x===void 0)return;var t=this.x();typeof t=="function"&&(this.u=t)}finally{e()}};pr.prototype.S=function(){if(1&this.f)throw new Error("Cycle detected");this.f|=1,this.f&=-9,ja(this),Ia(this),St++;var e=ae;return ae=this,eu.bind(this,e)};pr.prototype.N=function(){2&this.f||(this.f|=2,this.o=kr,kr=this)};pr.prototype.d=function(){this.f|=8,1&this.f||ho(this)};pr.prototype.dispose=function(){this.d()};function qt(e,t){var r=new pr(e,t);try{r.c()}catch(o){throw r.d(),o}var n=r.d.bind(r);return n[Symbol.dispose]=n,n}var Fa,vo,eo,Ua=[];qt(function(){Fa=this.N})();function fr(e,t){K[e]=t.bind(null,K[e]||function(){})}function _n(e){eo&&eo(),eo=e&&e.S()}function Na(e){var t=this,r=e.data,n=ru(r);n.value=r;var o=ur(function(){for(var s=t,c=t.__v;c=c.__;)if(c.__c){c.__c.__$f|=4;break}var l=ta(function(){var m=n.value.value;return m===0?0:m===!0?"":m||""}),u=ta(function(){return!Array.isArray(l.value)&&!pa(l.value)}),p=qt(function(){if(this.N=Da,u.value){var m=l.value;s.__v&&s.__v.__e&&s.__v.__e.nodeType===3&&(s.__v.__e.data=m)}}),d=t.__$u.d;return t.__$u.d=function(){p(),d.call(this)},[u,l]},[]),i=o[0],a=o[1];return i.value?a.peek():a.value}Na.displayName="ReactiveTextNode";Object.defineProperties(Ce.prototype,{constructor:{configurable:!0,value:void 0},type:{configurable:!0,value:Na},props:{configurable:!0,get:function(){return{data:this}}},__b:{configurable:!0,value:1}});fr("__b",function(e,t){if(typeof t.type=="function"&&typeof window<"u"&&window.__PREACT_SIGNALS_DEVTOOLS__&&window.__PREACT_SIGNALS_DEVTOOLS__.exitComponent(),typeof t.type=="string"){var r,n=t.props;for(var o in n)if(o!=="children"){var i=n[o];i instanceof Ce&&(r||(t.__np=r={}),r[o]=i,n[o]=i.peek())}}e(t)});fr("__r",function(e,t){if(typeof t.type=="function"&&typeof window<"u"&&window.__PREACT_SIGNALS_DEVTOOLS__&&window.__PREACT_SIGNALS_DEVTOOLS__.enterComponent(t),t.type!==ft){_n();var r,n=t.__c;n&&(n.__$f&=-2,(r=n.__$u)===void 0&&(n.__$u=r=(function(o){var i;return qt(function(){i=this}),i.c=function(){n.__$f|=1,n.setState({})},i})())),vo=n,_n(r)}e(t)});fr("__e",function(e,t,r,n){typeof window<"u"&&window.__PREACT_SIGNALS_DEVTOOLS__&&window.__PREACT_SIGNALS_DEVTOOLS__.exitComponent(),_n(),vo=void 0,e(t,r,n)});fr("diffed",function(e,t){typeof t.type=="function"&&typeof window<"u"&&window.__PREACT_SIGNALS_DEVTOOLS__&&window.__PREACT_SIGNALS_DEVTOOLS__.exitComponent(),_n(),vo=void 0;var r;if(typeof t.type=="string"&&(r=t.__e)){var n=t.__np,o=t.props;if(n){var i=r.U;if(i)for(var a in i){var s=i[a];s!==void 0&&!(a in n)&&(s.d(),i[a]=void 0)}else i={},r.U=i;for(var c in n){var l=i[c],u=n[c];l===void 0?(l=tu(r,c,u,o),i[c]=l):l.o(u,o)}}}e(t)});function tu(e,t,r,n){var o=t in e&&e.ownerSVGElement===void 0,i=Ot(r);return{o:function(a,s){i.value=a,n=s},d:qt(function(){this.N=Da;var a=i.value.value;n[t]!==a&&(n[t]=a,o?e[t]=a:a?e.setAttribute(t,a):e.removeAttribute(t))})}}fr("unmount",function(e,t){if(typeof t.type=="string"){var r=t.__e;if(r){var n=r.U;if(n){r.U=void 0;for(var o in n){var i=n[o];i&&i.d()}}}}else{var a=t.__c;if(a){var s=a.__$u;s&&(a.__$u=void 0,s.d())}}e(t)});fr("__h",function(e,t,r,n){(n<3||n===9)&&(t.__$f|=2),e(t,r,n)});at.prototype.shouldComponentUpdate=function(e,t){var r=this.__$u,n=r&&r.s!==void 0;for(var o in t)return!0;if(this.__f||typeof this.u=="boolean"&&this.u===!0){var i=2&this.__$f;if(!(n||i||4&this.__$f)||1&this.__$f)return!0}else if(!(n||4&this.__$f)||3&this.__$f)return!0;for(var a in e)if(a!=="__source"&&e[a]!==this.props[a])return!0;for(var s in this.props)if(!(s in e))return!0;return!1};function ru(e,t){return bn(function(){return Ot(e,t)})[0]}var nu=function(e){queueMicrotask(function(){queueMicrotask(e)})};function ou(){Ql(function(){for(var e;e=Ua.shift();)Fa.call(e)})}function Da(){Ua.push(this)===1&&(K.requestAnimationFrame||nu)(ou)}var ao=[0];for(let e=0;e<32;e++)ao.push(ao[e]|1<>>5]>>>e&1}set(e){this.data[e>>>5]|=1<<(e&31)}forEach(e){let t=this.size&31;for(let r=0;r{var r;return(r=t.tags)==null?void 0:r.length})&&(matchMedia("(max-width: 768px)").matches||Wa())}function Dt(){Qe.value=He(H({},Qe.value),{hideSearch:!Qe.value.hideSearch})}function Wa(){Qe.value=He(H({},Qe.value),{hideFilters:!Qe.value.hideFilters})}function dn(){return Qe.value.selectedItem}function so(e){Qe.value=He(H({},Qe.value),{selectedItem:e})}function su(){var e,t;return(t=(e=lr.value)==null?void 0:e.items)!=null?t:[]}function wn(){return typeof Oe.value.input=="string"?Oe.value.input:""}function Va(e){let t=za();e.length&&!t.length?Oe.value=He(H({},Oe.value),{page:void 0,input:e}):!e.length&&t.length?Oe.value=He(H({},Oe.value),{page:void 0,input:{type:"operator",data:{operator:"not",operands:[]}}}):Oe.value=He(H({},Oe.value),{page:void 0,input:e})}function cu(){typeof it.value.pagination.next<"u"&&(Oe.value=He(H({},Oe.value),{page:it.value.pagination.next}))}function lu(e){let t=Oe.value.filter.input;if("type"in t&&t.type==="operator"){for(let r of t.data.operands)if("type"in r&&r.type==="value"&&typeof r.data.value=="string"&&r.data.value===e)return!0}return!1}function za(){let e=Oe.value.filter.input,t=[];if("type"in e&&e.type==="operator")for(let r of e.data.operands)"type"in r&&r.type==="value"&&typeof r.data.value=="string"&&t.push(r.data.value);return t}function uu(e){let t=Oe.value.filter.input,r=[];if("type"in t&&t.type==="operator")for(let n of t.data.operands)"type"in n&&n.type==="value"&&typeof n.data.value=="string"&&r.push(n.data.value);if(r.includes(e)){let n=r.indexOf(e);n>-1&&r.splice(n,1)}else r.push(e);Oe.value=He(H({},Oe.value),{page:void 0,filter:He(H({},Oe.value.filter),{input:{type:"operator",data:{operator:"and",operands:r.map(n=>({type:"value",data:{field:"tags",value:n}}))}}})}),Va(wn())}function pu(){return it.value.items}function fu(){return it.value.total}function mu(){var e;for(let t of(e=it.value.aggregations)!=null?e:[])if(t.type==="term")return t.data.value;return[]}function sr(){return Qe.value.hideSearch}function du(){return Qe.value.hideFilters}function qa(){var e;return(e=Ka.value.highlight)!=null?e:!1}var Qe=Ot({hideSearch:!0,hideFilters:!0,selectedItem:0}),Ka=Ot({}),lr=Ot(),na=Ot(),Oe=Ot({input:"",filter:{input:{type:"operator",data:{operator:"and",operands:[]}},aggregation:{input:[{type:"term",data:{field:"tags"}}]}}}),it=Ot({items:[],query:{select:{documents:new ra(0),terms:new ra(0)},values:[]},pagination:{total:0}});function hu(e,t,r){for(let n=0;tr&&t(0,o,r,r=i);continue;case 62:e.charCodeAt(r+1)===47?t(2,--o,r,r=i+1):hu(e,r,n)?t(3,o,r,r=i+1):t(1,o++,r,r=i+1)}i>r&&t(0,o,r,i)}function bu(e,t=0,r=e.length){let n=++t;e:for(let l=0;n{let i=[],a=[],{onElement:s,onText:c=gu}=typeof r=="function"?{onElement:r}:r,l=0,u=0;return e(t,(p,d,m,h)=>{if(p===0)i[l++]=c(t,m,h),a[u++]={value:null,depth:d};else if(p&1&&(a[u++]={value:bu(t,m,h),depth:d}),p&2)for(let v=0;u>=0;v++){let{value:x,depth:w}=a[--u];if(w>d)continue;let E=i.slice(l-=v,l+v);i[l++]=s(x,E),u++;break}},n,o),i.slice(0,l)}}function yu(e){return e.replace(/[&<>]/g,t=>{switch(t.charCodeAt(0)){case 38:return"&";case 60:return"<";case 62:return">"}})}function hn(e){return e.replace(/&(amp|[lg]t);/g,t=>{switch(t.charCodeAt(1)){case 97:return"&";case 108:return"<";case 103:return">"}})}function xu(e,t){return{start:e.start+t,end:e.end+t,value:e.value}}function wu(e,t,r){return e.slice(t,r)}function Eu(e){let{onHighlight:t,onText:r=wu}=typeof e=="function"?{onHighlight:e}:e;return(n,o,i=0,a=n.length)=>{var l;let s=[],c=(l=o==null?void 0:o.ranges)!=null?l:[];for(let u=0,p=i;ua)break;let m=c[u].end;if(mi&&s.push(r(n,i,d));let{value:h}=c[u];s.push(t(n,{start:d,end:i=m,value:h}))}return i{let o=n.data;switch(o.type){case 1:na.value=!0;break;case 3:typeof o.data.pagination.prev<"u"?it.value=He(H({},it.value),{pagination:o.data.pagination,items:[...it.value.items,...o.data.items]}):(it.value=o.data,so(0));break}},qt(()=>{lr.value&&r.postMessage({type:0,data:lr.value})}),qt(()=>{na.value&&r.postMessage({type:2,data:Oe.value})})}var oa={container:"p",hidden:"m"};function ku(e){return z("div",{class:zt(oa.container,{[oa.hidden]:e.hidden}),onClick:()=>Dt()})}var ia={container:"r",disabled:"c"};function co(e){return z("button",{class:zt(ia.container,{[ia.disabled]:!e.onClick}),onClick:e.onClick,children:e.children})}var aa=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Au=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(t,r,n)=>n?n.toUpperCase():r.toLowerCase()),sa=e=>{let t=Au(e);return t.charAt(0).toUpperCase()+t.slice(1)},Cu=(...e)=>e.filter((t,r,n)=>!!t&&t.trim()!==""&&n.indexOf(t)===r).join(" ").trim(),Hu={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},$u=c=>{var l=c,{color:e="currentColor",size:t=24,strokeWidth:r=2,absoluteStrokeWidth:n,children:o,iconNode:i,class:a=""}=l,s=gr(l,["color","size","strokeWidth","absoluteStrokeWidth","children","iconNode","class"]);return Wt("svg",H(He(H({},Hu),{width:String(t),height:t,stroke:e,"stroke-width":n?Number(r)*24/Number(t):r,class:["lucide",a].join(" ")}),s),[...i.map(([u,p])=>Wt(u,p)),...Cr(o)])},bo=(e,t)=>{let r=a=>{var s=a,{class:n="",children:o}=s,i=gr(s,["class","children"]);return Wt($u,He(H({},i),{iconNode:t,class:Cu(`lucide-${aa(sa(e))}`,`lucide-${aa(e)}`,n)}),o)};return r.displayName=sa(e),r},Pu=bo("corner-down-left",[["path",{d:"M20 4v7a4 4 0 0 1-4 4H4",key:"6o5b7l"}],["path",{d:"m9 10-5 5 5 5",key:"1kshq7"}]]),Iu=bo("list-filter",[["path",{d:"M2 5h20",key:"1fs1ex"}],["path",{d:"M6 12h12",key:"8npq4p"}],["path",{d:"M9 19h6",key:"456am0"}]]),Ru=bo("search",[["path",{d:"m21 21-4.34-4.34",key:"14j7rj"}],["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}]]),Gx=hl(vl(),1);function ju({threshold:e=0,root:t=null,rootMargin:r="0%",freezeOnceVisible:n=!1,initialIsIntersecting:o=!1,onChange:i}={}){var a;let[s,c]=bn(null),[l,u]=bn(()=>({isIntersecting:o,entry:void 0})),p=Vt();p.current=i;let d=((a=l.entry)==null?void 0:a.isIntersecting)&&n;mt(()=>{if(!s||!("IntersectionObserver"in window)||d)return;let v,x=new IntersectionObserver(w=>{let E=Array.isArray(x.thresholds)?x.thresholds:[x.thresholds];w.forEach(_=>{let de=_.isIntersecting&&E.some(be=>_.intersectionRatio>=be);u({isIntersecting:de,entry:_}),p.current&&p.current(de,_),de&&n&&v&&(v(),v=void 0)})},{threshold:e,root:t,rootMargin:r});return x.observe(s),()=>{x.disconnect()}},[s,JSON.stringify(e),t,r,d,n]);let m=Vt(null);mt(()=>{var v;!s&&(v=l.entry)!=null&&v.target&&!n&&!d&&m.current!==l.entry.target&&(m.current=l.entry.target,u({isIntersecting:o,entry:void 0}))},[s,l.entry,n,d,o]);let h=[c,!!l.isIntersecting,l.entry];return h.ref=h[0],h.isIntersecting=h[1],h.entry=h[2],h}var lt={container:"n",hidden:"l",content:"u",pop:"d",badge:"y",sidebar:"i",controls:"w",results:"k",loadmore:"z"};function Fu(e){let{isIntersecting:t,ref:r}=ju({threshold:0});mt(()=>{t&&cu()},[t]);let n=Vt(null);mt(()=>{n.current&&typeof Oe.value.page>"u"&&n.current.scrollTo({top:0,behavior:"smooth"})},[Oe.value]);let o=za();return z("div",{class:zt(lt.container,{[lt.hidden]:e.hidden}),children:[z("div",{class:lt.content,children:[z("div",{class:lt.controls,children:[z(co,{onClick:Dt,children:z(Ru,{})}),z(Nu,{focus:!e.hidden}),z(co,{onClick:Wa,children:[z(Iu,{}),o.length>0&&z("span",{class:lt.badge,children:o.length})]})]}),z("div",{class:lt.results,ref:n,children:[z(Du,{keyboard:!e.hidden}),z("div",{class:lt.loadmore,ref:r})]})]}),z("div",{class:zt(lt.sidebar,{[lt.hidden]:du()}),children:z(Uu,{})})]})}var Tt={container:"X",list:"j",heading:"F",title:"I",item:"o",active:"g",value:"R",count:"q"};function Uu(e){let t=mu();return t.sort((r,n)=>n.node.count-r.node.count),z("div",{class:Tt.container,children:[z("h3",{class:Tt.heading,children:"Filters"}),z("h4",{class:Tt.title,children:"Tags"}),z("ol",{class:Tt.list,children:t.map(r=>z("li",{class:zt(Tt.item,{[Tt.active]:lu(r.node.value)}),onClick:()=>uu(r.node.value),children:[z("span",{class:Tt.value,children:r.node.value}),z("span",{class:Tt.count,children:r.node.count})]}))})]})}var ca={container:"f"};function Nu(e){let t=Vt(null);return mt(()=>{var r,n;e.focus?(r=t.current)==null||r.focus():(n=t.current)==null||n.blur()},[e.focus]),z("div",{class:ca.container,children:z("input",{ref:t,type:"text",class:ca.content,value:hn(wn()),onInput:r=>Va(yu(r.currentTarget.value)),autocapitalize:"off",autocomplete:"off",autocorrect:"off",placeholder:"Search",spellcheck:!1,role:"combobox"})})}var ut={container:"b",heading:"A",item:"a",active:"h",wrapper:"B",actions:"s",title:"x",path:"t"};function Ga(){let[e,t]=bn(!1);return mt(()=>{let r=()=>t(!0),n=()=>t(!1);return document.addEventListener("compositionstart",r),document.addEventListener("compositionend",n),()=>{document.removeEventListener("compositionstart",r),document.removeEventListener("compositionend",n)}},[]),e}function Du(e){var s;let t=su(),r=pu(),n=dn(),o=Vt([]),i=Ga();mt(()=>{let c=o.current[n];c&&c.scrollIntoView({block:"center",behavior:"smooth"})},[n]),Aa(e.keyboard,c=>{if(i)return;let l=dn();c.key==="ArrowDown"?(c.preventDefault(),so(Math.min(l+1,r.length-1))):c.key==="ArrowUp"&&(c.preventDefault(),so(Math.max(l-1,0)))},[e.keyboard,i]);let a=(s=fu())!=null?s:0;return z(ft,{children:[r.length>0&&z("h3",{class:ut.heading,children:[z("span",{class:ut.bubble,children:new Intl.NumberFormat("en-US").format(a)})," ","results"]}),z("ol",{class:ut.container,children:r.map((c,l)=>{var m;let u=Ba(t[c.id].title,c.matches.find(({field:h})=>h==="title")),p=Mu((m=t[c.id].path)!=null?m:[],c.matches.find(({field:h})=>h==="path")),d=t[c.id].location;if(qa()){let h=encodeURIComponent(wn()),[v,x]=d.split("#",2);d=`${v}?h=${h.replace(/%20/g,"+")}`,typeof x<"u"&&(d+=`#${x}`)}return z("li",{children:z("a",{ref:h=>{o.current[l]=h},href:d,onClick:()=>Dt(),class:zt(ut.item,{[ut.active]:l===dn()}),children:[z("div",{class:ut.wrapper,children:[z("h2",{class:ut.title,children:u}),z("menu",{class:ut.path,children:p.map(h=>z("li",{children:h}))})]}),z("nav",{class:ut.actions,children:z(co,{children:z(Pu,{})})})]})})})})]})}var Wu={container:"e"};function Vu(e){let t=Ga();return Aa(!0,r=>{var n,o,i,a,s;if(!t)if((r.metaKey||r.ctrlKey)&&r.key==="k")r.preventDefault(),Dt();else if((r.metaKey||r.ctrlKey)&&r.key==="j")document.body.classList.toggle("dark");else if(r.key==="Enter"&&!sr()){r.preventDefault();let c=dn(),l=(o=(n=it.value)==null?void 0:n.items[c])==null?void 0:o.id;if((a=(i=lr.value)==null?void 0:i.items[l])!=null&&a.location){Dt();let u=(s=lr.value)==null?void 0:s.items[l].location;if(qa()){let p=encodeURIComponent(wn()),[d,m]=u.split("#",2);u=`${d}?h=${p.replace(/%20/g,"+")}`,typeof m<"u"&&(u+=`#${m}`)}window.location.href=u}}else r.key==="Escape"&&!sr()&&(r.preventDefault(),Dt())},[t]),z("div",{class:Wu.container,children:[z(ku,{hidden:sr()}),z(Fu,{hidden:sr()})]})}function Ja(e,t){au(e),El(z(Vu,{}),t)}function go(){Dt()}function zu(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function qu(){return R(b(window,"compositionstart").pipe(f(()=>!0)),b(window,"compositionend").pipe(f(()=>!1))).pipe(J(!1))}function Xa(){let e=b(window,"keydown").pipe(f(t=>({mode:sr()?"global":"search",type:t.key,meta:t.ctrlKey||t.metaKey,claim(){t.preventDefault(),t.stopPropagation()}})),L(({mode:t,type:r})=>{if(t==="global"){let n=xt();if(typeof n!="undefined")return!zu(n,r)}return!0}),xe());return qu().pipe(g(t=>t?y:e))}function Ye(){return new URL(location.href)}function dt(e,t=!1){if(X("navigation.instant")&&!t){let r=A("a",{href:e.href});document.body.appendChild(r),r.click(),r.remove()}else location.href=e.href}function Za(){return new I}function Qa(){return location.hash.slice(1)}function es(e){let t=A("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function _o(e){return R(b(window,"hashchange"),e).pipe(f(Qa),J(Qa()),L(t=>t.length>0),f(decodeURIComponent),se(1))}function ts(e){return _o(e).pipe(f(t=>we(`[id="${t}"]`)),L(t=>typeof t!="undefined"))}function Ir(e){let t=matchMedia(e);return an(r=>t.addListener(()=>r(t.matches))).pipe(J(t.matches))}function rs(){let e=matchMedia("print");return R(b(window,"beforeprint").pipe(f(()=>!0)),b(window,"afterprint").pipe(f(()=>!1))).pipe(J(e.matches))}function yo(e,t){return e.pipe(g(r=>r?t():y))}function xo(e,t){return new U(r=>{let n=new XMLHttpRequest;return n.open("GET",`${e}`),n.responseType="blob",n.addEventListener("load",()=>{n.status>=200&&n.status<300?(r.next(n.response),r.complete()):r.error(new Error(n.statusText))}),n.addEventListener("error",()=>{r.error(new Error("Network error"))}),n.addEventListener("abort",()=>{r.complete()}),typeof(t==null?void 0:t.progress$)!="undefined"&&(n.addEventListener("progress",o=>{var i;if(o.lengthComputable)t.progress$.next(o.loaded/o.total*100);else{let a=(i=n.getResponseHeader("Content-Length"))!=null?i:0;t.progress$.next(o.loaded/+a*100)}}),t.progress$.next(5)),n.send(),()=>n.abort()})}function et(e,t){return xo(e,t).pipe(g(r=>r.text()),f(r=>JSON.parse(r)),se(1))}function En(e,t){let r=new DOMParser;return xo(e,t).pipe(g(n=>n.text()),f(n=>r.parseFromString(n,"text/html")),se(1))}function ns(e,t){let r=new DOMParser;return xo(e,t).pipe(g(n=>n.text()),f(n=>r.parseFromString(n,"text/xml")),se(1))}var wo={drawer:G("[data-md-toggle=drawer]"),search:G("[data-md-toggle=search]")};function Eo(e,t){wo[e].checked!==t&&wo[e].click()}function Tn(e){let t=wo[e];return b(t,"change").pipe(f(()=>t.checked),J(t.checked))}function os(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function is(){return R(b(window,"scroll",{passive:!0}),b(window,"resize",{passive:!0})).pipe(f(os),J(os()))}function as(){return{width:innerWidth,height:innerHeight}}function ss(){return b(window,"resize",{passive:!0}).pipe(f(as),J(as()))}function cs(){return re([is(),ss()]).pipe(f(([e,t])=>({offset:e,size:t})),se(1))}function Sn(e,{viewport$:t,header$:r}){let n=t.pipe(fe("size")),o=re([n,r]).pipe(f(()=>wt(e)));return re([r,t,o]).pipe(f(([{height:i},{offset:a,size:s},{x:c,y:l}])=>({offset:{x:a.x-c,y:a.y-l+i},size:s})))}var Ku=G("#__config"),mr=JSON.parse(Ku.textContent);mr.base=`${new URL(mr.base,Ye())}`;function Ue(){return mr}function X(e){return mr.features.includes(e)}function Bt(e,t){return typeof t!="undefined"?mr.translations[e].replace("#",t.toString()):mr.translations[e]}function ht(e,t=document){return G(`[data-md-component=${e}]`,t)}function Te(e,t=document){return P(`[data-md-component=${e}]`,t)}function Bu(e){let t=G(".md-typeset > :first-child",e);return b(t,"click",{once:!0}).pipe(f(()=>G(".md-typeset",e)),f(r=>({hash:__md_hash(r.innerHTML)})))}function ls(e){if(!X("announce.dismiss")||!e.childElementCount)return y;if(!e.hidden){let t=G(".md-typeset",e);__md_hash(t.innerHTML)===__md_get("__announce")&&(e.hidden=!0)}return j(()=>{let t=new I;return t.subscribe(({hash:r})=>{e.hidden=!0,__md_set("__announce",r)}),Bu(e).pipe($(r=>t.next(r)),V(()=>t.complete()),f(r=>H({ref:e},r)))})}function Yu(e,{target$:t}){return t.pipe(f(r=>({hidden:r!==e})))}function us(e,t){let r=new I;return r.subscribe(({hidden:n})=>{e.hidden=n}),Yu(e,t).pipe($(n=>r.next(n)),V(()=>r.complete()),f(n=>H({ref:e},n)))}function To(e,t){return t==="inline"?A("div",{class:"md-tooltip md-tooltip--inline",id:e,role:"tooltip"},A("div",{class:"md-tooltip__inner md-typeset"})):A("div",{class:"md-tooltip",id:e,role:"tooltip"},A("div",{class:"md-tooltip__inner md-typeset"}))}function On(...e){return A("div",{class:"md-tooltip2",role:"dialog"},A("div",{class:"md-tooltip2__inner md-typeset"},e))}function ps(...e){return A("div",{class:"md-tooltip2",role:"tooltip"},A("div",{class:"md-tooltip2__inner md-typeset"},e))}function fs(e,t){if(t=t?`${t}_annotation_${e}`:void 0,t){let r=t?`#${t}`:void 0;return A("aside",{class:"md-annotation",tabIndex:0},To(t),A("a",{href:r,class:"md-annotation__index",tabIndex:-1},A("span",{"data-md-annotation-id":e})))}else return A("aside",{class:"md-annotation",tabIndex:0},To(t),A("span",{class:"md-annotation__index",tabIndex:-1},A("span",{"data-md-annotation-id":e})))}function ms(e){return A("button",{class:"md-code__button",title:Bt("clipboard.copy"),"data-clipboard-target":`#${e} > code`,"data-md-type":"copy"})}function ds(){return A("button",{class:"md-code__button",title:"Toggle line selection","data-md-type":"select"})}function hs(){return A("nav",{class:"md-code__nav"})}var Xu=_r(So());function bs(e){return A("ul",{class:"md-source__facts"},Object.entries(e).map(([t,r])=>A("li",{class:`md-source__fact md-source__fact--${t}`},typeof r=="number"?Li(r):r)))}function Oo(e){let t=`tabbed-control tabbed-control--${e}`;return A("div",{class:t,hidden:!0},A("button",{class:"tabbed-button",tabIndex:-1,"aria-hidden":"true"}))}function gs(e){return A("div",{class:"md-typeset__scrollwrap"},A("div",{class:"md-typeset__table"},e))}function Zu(e){var n;let t=Ue(),r=new URL(`../${e.version}/`,t.base);return A("li",{class:"md-version__item"},A("a",{href:`${r}`,class:"md-version__link"},e.title,((n=t.version)==null?void 0:n.alias)&&e.aliases.length>0&&A("span",{class:"md-version__alias"},e.aliases[0])))}function _s(e,t){var n;let r=Ue();return e=e.filter(o=>{var i;return!((i=o.properties)!=null&&i.hidden)}),A("div",{class:"md-version"},A("button",{class:"md-version__current","aria-label":Bt("select.version")},t.title,((n=r.version)==null?void 0:n.alias)&&t.aliases.length>0&&A("span",{class:"md-version__alias"},t.aliases[0])),A("ul",{class:"md-version__list"},e.map(Zu)))}var Qu=0;function ep(e,t=250){let r=re([ir(e),Ft(e,t)]).pipe(f(([o,i])=>o||i),ie()),n=j(()=>Ai(e)).pipe(oe(Ut),Lr(1),Ze(r),f(()=>Ci(e)));return r.pipe(Sr(o=>o),g(()=>re([r,n])),f(([o,i])=>({active:o,offset:i})),xe())}function Rr(e,t,r=250){let{content$:n,viewport$:o}=t,i=`__tooltip2_${Qu++}`;return j(()=>{let a=new I,s=new Un(!1);a.pipe(he(),ye(!1)).subscribe(s);let c=s.pipe(Tr(u=>Ve(+!u*250,Wn)),ie(),g(u=>u?n:y),$(u=>u.id=i),xe());re([a.pipe(f(({active:u})=>u)),c.pipe(g(u=>Ft(u,250)),J(!1))]).pipe(f(u=>u.some(p=>p))).subscribe(s);let l=s.pipe(L(u=>u),pe(c,o),f(([u,p,{size:d}])=>{let m=e.getBoundingClientRect(),h=m.width/2;if(p.role==="tooltip")return{x:h,y:8+m.height};if(m.y>=d.height/2){let{height:v}=Ae(p);return{x:h,y:-16-v}}else return{x:h,y:16+m.height}}));return re([c,a,l]).subscribe(([u,{offset:p},d])=>{u.style.setProperty("--md-tooltip-host-x",`${p.x}px`),u.style.setProperty("--md-tooltip-host-y",`${p.y}px`),u.style.setProperty("--md-tooltip-x",`${d.x}px`),u.style.setProperty("--md-tooltip-y",`${d.y}px`),u.classList.toggle("md-tooltip2--top",d.y<0),u.classList.toggle("md-tooltip2--bottom",d.y>=0)}),s.pipe(L(u=>u),pe(c,(u,p)=>p),L(u=>u.role==="tooltip")).subscribe(u=>{let p=Ae(G(":scope > *",u));u.style.setProperty("--md-tooltip-width",`${p.width}px`),u.style.setProperty("--md-tooltip-tail","0px")}),s.pipe(ie(),Ie(je),pe(c)).subscribe(([u,p])=>{p.classList.toggle("md-tooltip2--active",u)}),re([s.pipe(L(u=>u)),c]).subscribe(([u,p])=>{p.role==="dialog"?(e.setAttribute("aria-controls",i),e.setAttribute("aria-haspopup","dialog")):e.setAttribute("aria-describedby",i)}),s.pipe(L(u=>!u)).subscribe(()=>{e.removeAttribute("aria-controls"),e.removeAttribute("aria-describedby"),e.removeAttribute("aria-haspopup")}),ep(e,r).pipe($(u=>a.next(u)),V(()=>a.complete()),f(u=>H({ref:e},u)))})}function Ge(e,{viewport$:t},r=document.body){return Rr(e,{content$:new U(n=>{let o=e.title,i=ps(o);return n.next(i),e.removeAttribute("title"),r.append(i),()=>{i.remove(),e.setAttribute("title",o)}}),viewport$:t},0)}function tp(e,t){let r=j(()=>re([Hi(e),Ut(t)])).pipe(f(([{x:n,y:o},i])=>{let{width:a,height:s}=Ae(e);return{x:n-i.x+a/2,y:o-i.y+s/2}}));return ir(e).pipe(g(n=>r.pipe(f(o=>({active:n,offset:o})),Me(+!n||1/0))))}function ys(e,t,{target$:r}){let[n,o]=Array.from(e.children);return j(()=>{let i=new I,a=i.pipe(he(),ye(!0));return i.subscribe({next({offset:s}){e.style.setProperty("--md-tooltip-x",`${s.x}px`),e.style.setProperty("--md-tooltip-y",`${s.y}px`)},complete(){e.style.removeProperty("--md-tooltip-x"),e.style.removeProperty("--md-tooltip-y")}}),Et(e).pipe(Q(a)).subscribe(s=>{e.toggleAttribute("data-md-visible",s)}),R(i.pipe(L(({active:s})=>s)),i.pipe(Be(250),L(({active:s})=>!s))).subscribe({next({active:s}){s?e.prepend(n):n.remove()},complete(){e.prepend(n)}}),i.pipe(Xe(16,je)).subscribe(({active:s})=>{n.classList.toggle("md-tooltip--active",s)}),i.pipe(Lr(125,je),L(()=>!!e.offsetParent),f(()=>e.offsetParent.getBoundingClientRect()),f(({x:s})=>s)).subscribe({next(s){s?e.style.setProperty("--md-tooltip-0",`${-s}px`):e.style.removeProperty("--md-tooltip-0")},complete(){e.style.removeProperty("--md-tooltip-0")}}),b(o,"click").pipe(Q(a),L(s=>!(s.metaKey||s.ctrlKey))).subscribe(s=>{s.stopPropagation(),s.preventDefault()}),b(o,"mousedown").pipe(Q(a),pe(i)).subscribe(([s,{active:c}])=>{var l;if(s.button!==0||s.metaKey||s.ctrlKey)s.preventDefault();else if(c){s.preventDefault();let u=e.parentElement.closest(".md-annotation");u instanceof HTMLElement?u.focus():(l=xt())==null||l.blur()}}),r.pipe(Q(a),L(s=>s===n),It(125)).subscribe(()=>e.focus()),tp(e,t).pipe($(s=>i.next(s)),V(()=>i.complete()),f(s=>H({ref:e},s)))})}function rp(e){let t=Ue();if(e.tagName!=="CODE")return[e];let r=[".c",".c1",".cm"];if(t.annotate){let n=e.closest("[class|=language]");if(n)for(let o of Array.from(n.classList)){if(!o.startsWith("language-"))continue;let[,i]=o.split("-");i in t.annotate&&r.push(...t.annotate[i])}}return P(r.join(", "),e)}function np(e){let t=[];for(let r of rp(e)){let n=[],o=document.createNodeIterator(r,NodeFilter.SHOW_TEXT);for(let i=o.nextNode();i;i=o.nextNode())n.push(i);for(let i of n){let a;for(;a=/(\(\d+\))(!)?/.exec(i.textContent);){let[,s,c]=a;if(typeof c=="undefined"){let l=i.splitText(a.index);i=l.splitText(s.length),t.push(l)}else{i.textContent=s,t.push(i);break}}}}return t}function xs(e,t){t.append(...Array.from(e.childNodes))}function Ln(e,t,{target$:r,print$:n}){let o=t.closest("[id]"),i=o==null?void 0:o.id,a=new Map;for(let s of np(t)){let[,c]=s.textContent.match(/\((\d+)\)/);we(`:scope > li:nth-child(${c})`,e)&&(a.set(c,fs(c,i)),s.replaceWith(a.get(c)))}return a.size===0?y:j(()=>{let s=new I,c=s.pipe(he(),ye(!0)),l=[];for(let[u,p]of a)l.push([G(".md-typeset",p),G(`:scope > li:nth-child(${u})`,e)]);return n.pipe(Q(c)).subscribe(u=>{e.hidden=!u,e.classList.toggle("md-annotation-list",u);for(let[p,d]of l)u?xs(p,d):xs(d,p)}),R(...[...a].map(([,u])=>ys(u,t,{target$:r}))).pipe(V(()=>s.complete()),xe())})}function ws(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return ws(t)}}function Es(e,t){return j(()=>{let r=ws(e);return typeof r!="undefined"?Ln(r,e,t):y})}var Ss=_r(Mo());var op=0,Ts=R(b(window,"keydown").pipe(f(()=>!0)),R(b(window,"keyup"),b(window,"contextmenu")).pipe(f(()=>!1))).pipe(J(!1),se(1));function Os(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Os(t)}}function ip(e){return Re(e).pipe(f(({width:t})=>({scrollable:Mr(e).width>t})),fe("scrollable"))}function Ls(e,t){let{matches:r}=matchMedia("(hover)"),n=j(()=>{let o=new I,i=o.pipe(Gn(1));o.subscribe(({scrollable:m})=>{m&&r?e.setAttribute("tabindex","0"):e.removeAttribute("tabindex")});let a=[],s=e.closest("pre"),c=s.closest("[id]"),l=c?c.id:op++;s.id=`__code_${l}`;let u=[],p=e.closest(".highlight");if(p instanceof HTMLElement){let m=Os(p);if(typeof m!="undefined"&&(p.classList.contains("annotate")||X("content.code.annotate"))){let h=Ln(m,e,t);u.push(Re(p).pipe(Q(i),f(({width:v,height:x})=>v&&x),ie(),g(v=>v?h:y)))}}let d=P(":scope > span[id]",e);if(d.length&&(e.classList.add("md-code__content"),e.closest(".select")||X("content.code.select")&&!e.closest(".no-select"))){let m=+d[0].id.split("-").pop(),h=ds();a.push(h),X("content.tooltips")&&u.push(Ge(h,{viewport$}));let v=b(h,"click").pipe(Or(M=>!M,!1),$(()=>h.blur()),xe());v.subscribe(M=>{h.classList.toggle("md-code__button--active",M)});let x=me(d).pipe(oe(M=>Ft(M).pipe(f(O=>[M,O]))));v.pipe(g(M=>M?x:y)).subscribe(([M,O])=>{let N=we(".hll.select",M);if(N&&!O)N.replaceWith(...Array.from(N.childNodes));else if(!N&&O){let ee=document.createElement("span");ee.className="hll select",ee.append(...Array.from(M.childNodes).slice(1)),M.append(ee)}});let w=me(d).pipe(oe(M=>b(M,"mousedown").pipe($(O=>O.preventDefault()),f(()=>M)))),E=v.pipe(g(M=>M?w:y),pe(Ts),f(([M,O])=>{var ee;let N=d.indexOf(M)+m;if(O===!1)return[N,N];{let le=P(".hll",e).map(ce=>d.indexOf(ce.parentElement)+m);return(ee=window.getSelection())==null||ee.removeAllRanges(),[Math.min(N,...le),Math.max(N,...le)]}})),_=_o(y).pipe(L(M=>M.startsWith(`__codelineno-${l}-`)));_.subscribe(M=>{let[,,O]=M.split("-"),N=O.split(":").map(le=>+le-m+1);N.length===1&&N.push(N[0]);for(let le of P(".hll:not(.select)",e))le.replaceWith(...Array.from(le.childNodes));let ee=d.slice(N[0]-1,N[1]);for(let le of ee){let ce=document.createElement("span");ce.className="hll",ce.append(...Array.from(le.childNodes).slice(1)),le.append(ce)}}),_.pipe(Me(1),Ie(ge)).subscribe(M=>{if(M.includes(":")){let O=document.getElementById(M.split(":")[0]);O&&setTimeout(()=>{let N=O,ee=-64;for(;N!==document.body;)ee+=N.offsetTop,N=N.offsetParent;window.scrollTo({top:ee})},1)}});let be=me(P('a[href^="#__codelineno"]',p)).pipe(oe(M=>b(M,"click").pipe($(O=>O.preventDefault()),f(()=>M)))).pipe(Q(i),pe(Ts),f(([M,O])=>{let ee=+G(`[id="${M.hash.slice(1)}"]`).parentElement.id.split("-").pop();if(O===!1)return[ee,ee];{let le=P(".hll",e).map(ce=>+ce.parentElement.id.split("-").pop());return[Math.min(ee,...le),Math.max(ee,...le)]}}));R(E,be).subscribe(M=>{let O=`#__codelineno-${l}-`;M[0]===M[1]?O+=M[0]:O+=`${M[0]}:${M[1]}`,history.replaceState({},"",O),window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.origin+window.location.pathname+O,oldURL:window.location.href}))})}if(Ss.default.isSupported()&&(e.closest(".copy")||X("content.code.copy")&&!e.closest(".no-copy"))){let m=ms(s.id);a.push(m),X("content.tooltips")&&u.push(Ge(m,{viewport$}))}if(a.length){let m=hs();m.append(...a),s.insertBefore(m,e)}return ip(e).pipe($(m=>o.next(m)),V(()=>o.complete()),f(m=>H({ref:e},m)),Rt(R(...u).pipe(Q(i))))});return X("content.lazy")?Et(e).pipe(L(o=>o),Me(1),g(()=>n)):n}function ap(e,{target$:t,print$:r}){let n=!0;return R(t.pipe(f(o=>o.closest("details:not([open])")),L(o=>e===o),f(()=>({action:"open",reveal:!0}))),r.pipe(L(o=>o||!n),$(()=>n=e.open),f(o=>({action:o?"open":"close"}))))}function Ms(e,t){return j(()=>{let r=new I;return r.subscribe(({action:n,reveal:o})=>{e.toggleAttribute("open",n==="open"),o&&e.scrollIntoView()}),ap(e,t).pipe($(n=>r.next(n)),V(()=>r.complete()),f(n=>H({ref:e},n)))})}var ks=0,As=new Map;function sp(e){let t=document.createElement("h3");t.innerHTML=e.innerHTML;let r=[t],n=e.nextElementSibling;for(;n&&!(n instanceof HTMLHeadingElement);)r.push(n.cloneNode(!0)),n=n.nextElementSibling;return r}function cp(e,t){for(let r of P("[href], [src]",e))for(let n of["href","src"]){let o=r.getAttribute(n);if(o&&!/^(?:[a-z]+:)?\/\//i.test(o)){r[n]=new URL(r.getAttribute(n),t).toString();break}}for(let r of P("[name^=__], [for]",e))for(let n of["id","for","name"]){let o=r.getAttribute(n);o&&r.setAttribute(n,`${o}$preview_${ks}`)}return ks++,Y(e)}function lp(e){let t=As.get(e.toString());return t?Y(t):En(e).pipe(g(r=>cp(r,e)),f(r=>(As.set(e.toString(),r),r)))}function Cs(e,t){let{sitemap$:r}=t;if(!(e instanceof HTMLAnchorElement))return y;if(!(X("navigation.instant.preview")||e.hasAttribute("data-preview")))return y;e.removeAttribute("title");let n=re([ir(e),Ft(e).pipe(ke(1))]).pipe(f(([i,a])=>i||a),ie(),L(i=>i));return $t([r,n]).pipe(g(([i])=>{let a=new URL(e.href);return a.search=a.hash="",i.has(`${a}`)?Y(a):y}),g(i=>lp(i)),g(i=>{let a=e.hash?`article [id="${decodeURIComponent(e.hash.slice(1))}"]`:"article h1",s=we(a,i);return typeof s=="undefined"?y:Y(sp(s))})).pipe(g(i=>{let a=new U(s=>{let c=On(...i);return s.next(c),document.body.append(c),()=>c.remove()});return Rr(e,H({content$:a},t))}))}var Hs=".node circle,.node ellipse,.node path,.node polygon,.node rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}marker{fill:var(--md-mermaid-edge-color)!important}.edgeLabel .label rect{fill:#0000}.flowchartTitleText{fill:var(--md-mermaid-label-fg-color)}.label{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.label foreignObject{line-height:normal;overflow:visible}.label div .edgeLabel{color:var(--md-mermaid-label-fg-color)}.edgeLabel,.edgeLabel p,.label div .edgeLabel{background-color:var(--md-mermaid-label-bg-color)}.edgeLabel,.edgeLabel p{fill:var(--md-mermaid-label-bg-color);color:var(--md-mermaid-edge-color)}.edgePath .path,.flowchart-link{stroke:var(--md-mermaid-edge-color)}.edgePath .arrowheadPath{fill:var(--md-mermaid-edge-color);stroke:none}.cluster rect{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}.cluster span{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}g #flowchart-circleEnd,g #flowchart-circleStart,g #flowchart-crossEnd,g #flowchart-crossStart,g #flowchart-pointEnd,g #flowchart-pointStart{stroke:none}.classDiagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.classGroup line,g.classGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.classGroup text{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.classLabel .box{fill:var(--md-mermaid-label-bg-color);background-color:var(--md-mermaid-label-bg-color);opacity:1}.classLabel .label{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.node .divider{stroke:var(--md-mermaid-node-fg-color)}.relation{stroke:var(--md-mermaid-edge-color)}.cardinality{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.cardinality text{fill:inherit!important}defs marker.marker.composition.class path,defs marker.marker.dependency.class path,defs marker.marker.extension.class path{fill:var(--md-mermaid-edge-color)!important;stroke:var(--md-mermaid-edge-color)!important}defs marker.marker.aggregation.class path{fill:var(--md-mermaid-label-bg-color)!important;stroke:var(--md-mermaid-edge-color)!important}.statediagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.stateGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.stateGroup .state-title{fill:var(--md-mermaid-label-fg-color)!important;font-family:var(--md-mermaid-font-family)}g.stateGroup .composit{fill:var(--md-mermaid-label-bg-color)}.nodeLabel,.nodeLabel p{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}a .nodeLabel{text-decoration:underline}.node circle.state-end,.node circle.state-start,.start-state{fill:var(--md-mermaid-edge-color);stroke:none}.end-state-inner,.end-state-outer{fill:var(--md-mermaid-edge-color)}.end-state-inner,.node circle.state-end{stroke:var(--md-mermaid-label-bg-color)}.transition{stroke:var(--md-mermaid-edge-color)}[id^=state-fork] rect,[id^=state-join] rect{fill:var(--md-mermaid-edge-color)!important;stroke:none!important}.statediagram-cluster.statediagram-cluster .inner{fill:var(--md-default-bg-color)}.statediagram-cluster rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}.statediagram-state rect.divider{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}defs #statediagram-barbEnd{stroke:var(--md-mermaid-edge-color)}[id^=entity] path,[id^=entity] rect{fill:var(--md-default-bg-color)}.relationshipLine{stroke:var(--md-mermaid-edge-color)}defs .marker.oneOrMore.er *,defs .marker.onlyOne.er *,defs .marker.zeroOrMore.er *,defs .marker.zeroOrOne.er *{stroke:var(--md-mermaid-edge-color)!important}text:not([class]):last-child{fill:var(--md-mermaid-label-fg-color)}.actor{fill:var(--md-mermaid-sequence-actor-bg-color);stroke:var(--md-mermaid-sequence-actor-border-color)}text.actor>tspan{fill:var(--md-mermaid-sequence-actor-fg-color);font-family:var(--md-mermaid-font-family)}line{stroke:var(--md-mermaid-sequence-actor-line-color)}.actor-man circle,.actor-man line{fill:var(--md-mermaid-sequence-actorman-bg-color);stroke:var(--md-mermaid-sequence-actorman-line-color)}.messageLine0,.messageLine1{stroke:var(--md-mermaid-sequence-message-line-color)}.note{fill:var(--md-mermaid-sequence-note-bg-color);stroke:var(--md-mermaid-sequence-note-border-color)}.loopText,.loopText>tspan,.messageText,.noteText>tspan{stroke:none;font-family:var(--md-mermaid-font-family)!important}.messageText{fill:var(--md-mermaid-sequence-message-fg-color)}.loopText,.loopText>tspan{fill:var(--md-mermaid-sequence-loop-fg-color)}.noteText>tspan{fill:var(--md-mermaid-sequence-note-fg-color)}#arrowhead path{fill:var(--md-mermaid-sequence-message-line-color);stroke:none}.loopLine{fill:var(--md-mermaid-sequence-loop-bg-color);stroke:var(--md-mermaid-sequence-loop-border-color)}.labelBox{fill:var(--md-mermaid-sequence-label-bg-color);stroke:none}.labelText,.labelText>span{fill:var(--md-mermaid-sequence-label-fg-color);font-family:var(--md-mermaid-font-family)}.sequenceNumber{fill:var(--md-mermaid-sequence-number-fg-color)}rect.rect{fill:var(--md-mermaid-sequence-box-bg-color);stroke:none}rect.rect+text.text{fill:var(--md-mermaid-sequence-box-fg-color)}defs #sequencenumber{fill:var(--md-mermaid-sequence-number-bg-color)!important}";var ko,pp=0;function fp(){return typeof mermaid=="undefined"||mermaid instanceof Element?ar("https://unpkg.com/mermaid@11/dist/mermaid.min.js"):Y(void 0)}function $s(e){return e.classList.remove("mermaid"),ko||(ko=fp().pipe($(()=>mermaid.initialize({startOnLoad:!1,themeCSS:Hs,sequence:{actorFontSize:"16px",messageFontSize:"16px",noteFontSize:"16px"}})),f(()=>{}),se(1))),ko.subscribe(()=>Uo(null,null,function*(){e.classList.add("mermaid");let t=`__mermaid_${pp++}`,r=A("div",{class:"mermaid"}),n=e.textContent,{svg:o,fn:i}=yield mermaid.render(t,n),a=r.attachShadow({mode:"closed"});a.innerHTML=o,e.replaceWith(r),i==null||i(a)})),ko.pipe(f(()=>({ref:e})))}var Ps=A("table");function Is(e){return e.replaceWith(Ps),Ps.replaceWith(gs(e)),Y({ref:e})}function mp(e){let t=e.find(r=>r.checked)||e[0];return R(...e.map(r=>b(r,"change").pipe(f(()=>G(`label[for="${r.id}"]`))))).pipe(J(G(`label[for="${t.id}"]`)),f(r=>({active:r})))}function Rs(e,{viewport$:t,target$:r}){let n=G(".tabbed-labels",e),o=P(":scope > input",e),i=Oo("prev");e.append(i);let a=Oo("next");return e.append(a),j(()=>{let s=new I,c=s.pipe(he(),ye(!0));re([s,Re(e),Et(e)]).pipe(Q(c),Xe(1,je)).subscribe({next([{active:l},u]){let p=wt(l),{width:d}=Ae(l);e.style.setProperty("--md-indicator-x",`${p.x}px`),e.style.setProperty("--md-indicator-width",`${d}px`);let m=ln(n);(p.xm.x+u.width)&&n.scrollTo({left:Math.max(0,p.x-16),behavior:"smooth"})},complete(){e.style.removeProperty("--md-indicator-x"),e.style.removeProperty("--md-indicator-width")}}),re([Ut(n),Re(n)]).pipe(Q(c)).subscribe(([l,u])=>{let p=Mr(n);i.hidden=l.x<16,a.hidden=l.x>p.width-u.width-16}),R(b(i,"click").pipe(f(()=>-1)),b(a,"click").pipe(f(()=>1))).pipe(Q(c)).subscribe(l=>{let{width:u}=Ae(n);n.scrollBy({left:u*l,behavior:"smooth"})}),r.pipe(Q(c),L(l=>o.includes(l))).subscribe(l=>l.click()),n.classList.add("tabbed-labels--linked");for(let l of o){let u=G(`label[for="${l.id}"]`);u.replaceChildren(A("a",{href:`#${u.htmlFor}`,tabIndex:-1},...Array.from(u.childNodes))),b(u.firstElementChild,"click").pipe(Q(c),L(p=>!(p.metaKey||p.ctrlKey)),$(p=>{p.preventDefault(),p.stopPropagation()})).subscribe(()=>{history.replaceState({},"",`#${u.htmlFor}`),u.click()})}return X("content.tabs.link")&&s.pipe(ke(1),pe(t)).subscribe(([{active:l},{offset:u}])=>{let p=l.innerText.trim();if(l.hasAttribute("data-md-switching"))l.removeAttribute("data-md-switching");else{let d=e.offsetTop-u.y;for(let h of P("[data-tabs]"))for(let v of P(":scope > input",h)){let x=G(`label[for="${v.id}"]`);if(x!==l&&x.innerText.trim()===p){x.setAttribute("data-md-switching",""),v.click();break}}window.scrollTo({top:e.offsetTop-d});let m=__md_get("__tabs")||[];__md_set("__tabs",[...new Set([p,...m])])}}),s.pipe(Q(c)).subscribe(()=>{for(let l of P("audio, video",e))l.offsetWidth&&l.autoplay?l.play().catch(()=>{}):l.pause()}),mp(o).pipe($(l=>s.next(l)),V(()=>s.complete()),f(l=>H({ref:e},l)))}).pipe(Ht(ge))}function js(e,t){let{viewport$:r,target$:n,print$:o}=t;return R(...P(".annotate:not(.highlight)",e).map(i=>Es(i,{target$:n,print$:o})),...P("pre:not(.mermaid) > code",e).map(i=>Ls(i,{target$:n,print$:o})),...P("a",e).map(i=>Cs(i,t)),...P("pre.mermaid",e).map(i=>$s(i)),...P("table:not([class])",e).map(i=>Is(i)),...P("details",e).map(i=>Ms(i,{target$:n,print$:o})),...P("[data-tabs]",e).map(i=>Rs(i,{viewport$:r,target$:n})),...P("[title]:not([data-preview])",e).filter(()=>X("content.tooltips")).map(i=>Ge(i,{viewport$:r})),...P(".footnote-ref",e).filter(()=>X("content.footnote.tooltips")).map(i=>Rr(i,{content$:new U(a=>{let s=new URL(i.href).hash.slice(1),c=Array.from(document.getElementById(s).cloneNode(!0).children),l=On(...c);return a.next(l),document.body.append(l),()=>l.remove()}),viewport$:r})))}function dp(e,{alert$:t}){return t.pipe(g(r=>R(Y(!0),Y(!1).pipe(It(2e3))).pipe(f(n=>({message:r,active:n})))))}function Fs(e,t){let r=G(".md-typeset",e);return j(()=>{let n=new I;return n.subscribe(({message:o,active:i})=>{e.classList.toggle("md-dialog--active",i),r.textContent=o}),dp(e,t).pipe($(o=>n.next(o)),V(()=>n.complete()),f(o=>H({ref:e},o)))})}function hp({viewport$:e}){if(!X("header.autohide"))return Y(!1);let t=e.pipe(f(({offset:{y:o}})=>o),Pt(2,1),f(([o,i])=>[oMath.abs(i-o.y)>100),f(([,[o]])=>o),ie()),n=Tn("search");return re([e,n]).pipe(f(([{offset:o},i])=>o.y>400&&!i),ie(),g(o=>o?r:Y(!1)),J(!1))}function Us(e,t){return j(()=>re([Re(e),hp(t)])).pipe(f(([{height:r},n])=>({height:r,hidden:n})),ie((r,n)=>r.height===n.height&&r.hidden===n.hidden),se(1))}function Ns(e,{viewport$:t,header$:r,main$:n}){return j(()=>{let o=new I,i=o.pipe(he(),ye(!0));o.pipe(fe("active"),Ze(r)).subscribe(([{active:s},{hidden:c}])=>{e.classList.toggle("md-header--shadow",s&&!c),e.hidden=c});let a=me(P("[title]",e)).pipe(L(()=>X("content.tooltips")),oe(s=>Ge(s,{viewport$:t})));return n.subscribe(o),r.pipe(Q(i),f(s=>H({ref:e},s)),Rt(a.pipe(Q(i))))})}function vp(e,{viewport$:t,header$:r}){return Sn(e,{viewport$:t,header$:r}).pipe(f(({offset:{y:n}})=>{let{height:o}=Ae(e);return{active:o>0&&n>=o}}),fe("active"))}function Ds(e,t){return j(()=>{let r=new I;r.subscribe({next({active:o}){e.classList.toggle("md-header__title--active",o)},complete(){e.classList.remove("md-header__title--active")}});let n=we(".md-content h1");return typeof n=="undefined"?y:vp(n,t).pipe($(o=>r.next(o)),V(()=>r.complete()),f(o=>H({ref:e},o)))})}function Ws(e,{viewport$:t,header$:r}){let n=r.pipe(f(({height:i})=>i),ie()),o=n.pipe(g(()=>Re(e).pipe(f(({height:i})=>({top:e.offsetTop,bottom:e.offsetTop+i})),fe("bottom"))));return re([n,o,t]).pipe(f(([i,{top:a,bottom:s},{offset:{y:c},size:{height:l}}])=>(l=Math.max(0,l-Math.max(0,a-c,i)-Math.max(0,l+c-s)),{offset:a-i,height:l,active:a-i<=c})),ie((i,a)=>i.offset===a.offset&&i.height===a.height&&i.active===a.active))}function bp(e){let t=__md_get("__palette")||{index:e.findIndex(n=>matchMedia(n.getAttribute("data-md-color-media")).matches)},r=Math.max(0,Math.min(t.index,e.length-1));return Y(...e).pipe(oe(n=>b(n,"change").pipe(f(()=>n))),J(e[r]),f(n=>({index:e.indexOf(n),color:{media:n.getAttribute("data-md-color-media"),scheme:n.getAttribute("data-md-color-scheme"),primary:n.getAttribute("data-md-color-primary"),accent:n.getAttribute("data-md-color-accent")}})),se(1))}function Vs(e){let t=P("input",e),r=A("meta",{name:"theme-color"});document.head.appendChild(r);let n=A("meta",{name:"color-scheme"});document.head.appendChild(n);let o=Ir("(prefers-color-scheme: light)");return j(()=>{let i=new I;return i.subscribe(a=>{if(document.body.setAttribute("data-md-color-switching",""),a.color.media==="(prefers-color-scheme)"){let s=matchMedia("(prefers-color-scheme: light)"),c=document.querySelector(s.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");a.color.scheme=c.getAttribute("data-md-color-scheme"),a.color.primary=c.getAttribute("data-md-color-primary"),a.color.accent=c.getAttribute("data-md-color-accent")}for(let[s,c]of Object.entries(a.color))document.body.setAttribute(`data-md-color-${s}`,c);for(let s=0;sa.key==="Enter"),pe(i,(a,s)=>s)).subscribe(({index:a})=>{a=(a+1)%t.length,t[a].click(),t[a].focus()}),i.pipe(f(()=>{let a=ht("header"),s=window.getComputedStyle(a);return n.content=s.colorScheme,s.backgroundColor.match(/\d+/g).map(c=>(+c).toString(16).padStart(2,"0")).join("")})).subscribe(a=>r.content=`#${a}`),i.pipe(Ie(ge)).subscribe(()=>{document.body.removeAttribute("data-md-color-switching")}),bp(t).pipe(Q(o.pipe(ke(1))),jt(),$(a=>i.next(a)),V(()=>i.complete()),f(a=>H({ref:e},a)))})}function zs(e,{progress$:t}){return j(()=>{let r=new I;return r.subscribe(({value:n})=>{e.style.setProperty("--md-progress-value",`${n}`)}),t.pipe($(n=>r.next({value:n})),V(()=>r.complete()),f(n=>({ref:e,value:n})))})}var qs='.v u{text-decoration:underline!important;text-decoration-style:wavy!important;text-decoration-thickness:1px!important}.p{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);background-color:rgba(var(--color-backdrop)/var(--alpha-lighter));cursor:pointer;height:100%;pointer-events:auto;position:absolute;transition:opacity .25s;width:100%}.p.m{opacity:0;pointer-events:none;transition:opacity .35s}.r{align-items:center;background-color:initial;border:none;border-radius:var(--space-2);cursor:pointer;display:flex;flex-shrink:0;font-family:var(--font-family);height:36px;justify-content:center;outline:none;padding:0;position:relative;transition:background-color .25s,color .25s;width:36px;z-index:1}.r svg{stroke:rgb(var(--color-foreground));height:18px;opacity:.5;width:18px}.r:before{background-color:rgb(var(--color-background-subtle));border-radius:var(--border-radius-2);content:"";inset:0;opacity:0;position:absolute;transform:scale(.75);transition:transform 125ms,opacity 125ms;z-index:0}.r:hover:before{opacity:1;transform:scale(1)}.r.c{cursor:auto}.r.c:before{display:none}.n{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);background-color:rgba(var(--color-background)/var(--alpha-light));border-radius:var(--space-3);box-shadow:0 0 60px #0000000d;display:flex;height:480px;overflow:hidden;pointer-events:auto;position:absolute;transition:transform .25s cubic-bezier(.16,1,.3,1),opacity .25s;width:640px}.n.l{opacity:0;pointer-events:none;transform:scale(1.1);transition:transform .25s .15s,opacity .15s}@media (max-width:680px){.n{border-radius:0;height:100%;width:100%}}.u{display:flex;flex-basis:min-content;flex-direction:column;flex-grow:1;flex-shrink:0}@keyframes d{0%{transform:scale(0)}50%{transform:scale(1.2)}to{transform:scale(1)}}.y{animation:d .25s ease-in-out;background:var(--color-highlight);border-radius:100%;color:#fff;font-size:8px;font-weight:700;height:12px;padding-top:1px;position:absolute;right:4px;top:4px;width:12px}.i{background-color:rgb(var(--color-background-subtle)/var(--alpha-lighter));flex-shrink:0;overflow:scroll;position:relative;transition:width .35s cubic-bezier(.16,1,.3,1),opacity .25s;width:200px}.i>*{transform:translate(0);transition:transform .25s cubic-bezier(.16,1,.3,1)}.i.l{opacity:0;width:0}.i.l>*{transform:translate(-48px)}@media (max-width:680px){.i{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);background-color:rgba(var(--color-background-subtle)/var(--alpha-light));box-shadow:0 0 60px #00000026;height:100%;position:absolute;right:0;top:0}}.w{border-bottom:1px solid rgb(var(--color-foreground)/var(--alpha-lightest));display:flex;gap:var(--space-1);padding:var(--space-2)}.k{-webkit-overflow-scrolling:touch;overflow:auto;overscroll-behavior:contain}.z{padding:8px 10px}.X{color:rgb(var(--color-foreground)/var(--alpha-light));padding:var(--space-2);position:absolute;width:200px}.X,.j{display:flex;flex-direction:column}.j{gap:2px;list-style:none;padding:0}.F,.j{margin:0}.F{font-size:16px;font-weight:400}.F,.I{padding:8px}.I{font-size:14px;margin:4px 0 0;opacity:.5}.I,.o{font-size:12px}.o{cursor:pointer;display:flex;padding:4px 8px;position:relative}.o:before{background-color:var(--color-highlight-transparent);border-radius:var(--space-1);content:"";inset:0;opacity:0;position:absolute;transform:scale(.75);transition:transform 125ms,opacity 125ms;z-index:0}.o.g:before,.o:hover:before{opacity:1;transform:scale(1)}.o.g,.o:hover{color:var(--color-highlight)}.R{flex-grow:1}.R,.q{position:relative}.q{font-weight:700}.f{flex-grow:1}.f input{background:#0000;border:none;color:rgb(var(--color-foreground));font-family:var(--font-family);font-size:16px;height:100%;letter-spacing:-.25px;outline:none;width:100%}.b{color:rgb(var(--color-foreground)/var(--alpha-light));display:flex;flex-direction:column;gap:2px;line-height:1.3;list-style:none;margin:var(--space-2);margin-top:0;padding:0}.A,.b li{margin:0}.A{color:rgb(var(--color-foreground)/var(--alpha-lighter));font-size:12px;margin-top:var(--space-2);padding:0 18px}.a{border-radius:var(--space-2);color:inherit;cursor:pointer;display:flex;flex-direction:row;flex-grow:1;padding:8px 10px;position:relative;text-decoration:none}.a:before{background-color:rgb(var(--color-background-subtle));border-radius:var(--border-radius-2);content:"";display:block;inset:0;opacity:0;position:absolute;transform:scale(.9);transition:transform 125ms,opacity 125ms;z-index:0}@media (pointer:fine){.a.h:before,.a:hover:before{opacity:1;transform:scale(1)}}.a mark{background:#0000;color:var(--color-highlight)}.a u{background-color:var(--color-highlight-transparent);border-radius:2px;box-shadow:0 0 0 1px var(--color-highlight-transparent);text-decoration:none}.B{flex-grow:1}.s{margin-right:-8px;opacity:0;position:relative;transform:translate(-2px);transition:transform 125ms,opacity 125ms;z-index:0}@media (pointer:fine){.h>.s,:hover>.s{opacity:1;transform:none}}.x{font-size:14px;margin:0;position:relative}.x code{background:rgb(var(--color-background-subtle));border-radius:var(--space-1);font-size:13px;padding:2px 4px}.t{color:rgb(var(--color-foreground)/var(--alpha-lighter));display:inline-flex;flex-wrap:wrap;font-size:12px;gap:var(--space-1);list-style:none;margin:0;padding:0;position:relative}.t li{white-space:nowrap}.t li:after{content:"/";display:inline;margin-left:var(--space-1)}.t li:last-child:after{content:"";display:none}.e{--space-1:4px;--space-2:calc(var(--space-1)*2);--space-3:calc(var(--space-2)*2);--space-4:calc(var(--space-3)*2);--space-5:calc(var(--space-4)*2);--alpha-light:.7;--alpha-lighter:.54;--alpha-lightest:.1;--color-highlight:var(--md-accent-fg-color,#526cfe);--color-highlight-transparent:var(--md-accent-fg-color--transparent,#526cfe1a);--border-radius-1:var(--space-1);--border-radius-2:var(--space-2);--border-radius-3:calc(var(--space-1) + var(--space-2));--font-family:var(--md-text-font-family,Inter,Roboto Flex,system-ui,sans-serif);--font-size:16px;--line-height:1.5;--letter-spacing:-.5px;-webkit-font-smoothing:antialiased;align-items:center;display:flex;font-family:var(--font-family);font-size:var(--font-size);height:100vh;justify-content:center;letter-spacing:var(--letter-spacing);line-height:var(--line-height);pointer-events:none;position:absolute;width:100vw}@media (pointer:coarse){.e{height:-webkit-fill-available}}.e *,.e :after,.e :before{box-sizing:border-box}';function Ks(e,{index$:t}){let r=Ue(),n=document.createElement("div");document.body.appendChild(n),n.style.position="fixed",n.style.height="100%",n.style.top="0",n.style.zIndex="4";let o=n.attachShadow({mode:"open"});o.appendChild(A("style",{},qs.toString()));try{Ya(r.search,{highlight:r.features.includes("search.highlight")}),me(t).subscribe(i=>{for(let a of i.items)a.location=new URL(a.location,r.base).toString();Ja(i,o)}),b(e,"click").subscribe(()=>{go()}),Tn("search").pipe(ke(1)).subscribe(()=>go())}catch(i){e.hidden=!0;let a=G("label[for=__search]");a.hidden=!0}return Ke}var Bs=_r(So());function Ys(e,{index$:t,location$:r}){return re([t,r.pipe(J(Ye()),L(n=>!!n.searchParams.get("h")))]).pipe(f(([n,o])=>_p(n.config)(o.searchParams.get("h"))),f(n=>{var a;let o=new Map,i=document.createNodeIterator(e,NodeFilter.SHOW_TEXT);for(let s=i.nextNode();s;s=i.nextNode())if((a=s.parentElement)!=null&&a.offsetHeight){let c=s.textContent,l=n(c);l.length>c.length&&o.set(s,l)}for(let[s,c]of o){let{childNodes:l}=A("span",null,c);s.replaceWith(...Array.from(l))}return{ref:e,nodes:o}}))}function _p(e){let t=e.separator.split("|").map(o=>o.replace(/(\(\?[!=<][^)]+\))/g,"").length===0?"\uFFFD":o).join("|"),r=new RegExp(t,"img"),n=(o,i,a)=>`${i}${a}`;return o=>{o=o.replace(/\s+/g," ").replace(/&/g,"&").trim();let i=new RegExp(`(^|${e.separator}|)(${o.split(r).map(a=>a.replace(/[|\\{}()[\]^$+*?.-]/g,"\\$&")).filter(a=>a.length>0).join("|")})`,"img");return a=>(0,Bs.default)(a).replace(i,n).replace(/<\/mark>(\s+)]*>/img,"$1")}}function yp(e,{viewport$:t,main$:r}){let n=e.closest(".md-grid"),o=n.offsetTop-n.parentElement.offsetTop;return re([r,t]).pipe(f(([{offset:i,height:a},{offset:{y:s}}])=>(a=a+Math.min(o,Math.max(0,s-i))-o,{height:a,locked:s>=i+o})),ie((i,a)=>i.height===a.height&&i.locked===a.locked))}function Ao(e,n){var o=n,{header$:t}=o,r=gr(o,["header$"]);let i=G(".md-sidebar__scrollwrap",e),{y:a}=wt(i);return j(()=>{let s=new I,c=s.pipe(he(),ye(!0)),l=s.pipe(Xe(0,je));return l.pipe(pe(t)).subscribe({next([{height:u},{height:p}]){i.style.height=`${u-2*a}px`,e.style.top=`${p}px`},complete(){i.style.height="",e.style.top=""}}),l.pipe(Sr()).subscribe(()=>{for(let u of P(".md-nav__link--active[href]",e)){if(!u.clientHeight)continue;let p=u.closest(".md-sidebar__scrollwrap");if(typeof p!="undefined"){let d=u.offsetTop-p.offsetTop,{height:m}=Ae(p);p.scrollTo({top:d-m/2})}}}),me(P("label[tabindex]",e)).pipe(oe(u=>b(u,"click").pipe(Ie(ge),f(()=>u),Q(c)))).subscribe(u=>{let p=G(`[id="${u.htmlFor}"]`);G(`[aria-labelledby="${u.id}"]`).setAttribute("aria-expanded",`${p.checked}`)}),X("content.tooltips")&&me(P("abbr[title]",e)).pipe(oe(u=>Ge(u,{viewport$})),Q(c)).subscribe(),yp(e,r).pipe($(u=>s.next(u)),V(()=>s.complete()),f(u=>H({ref:e},u)))})}function Gs(e,t){if(typeof t!="undefined"){let r=`https://api.github.com/repos/${e}/${t}`;return $t(et(`${r}/releases/latest`).pipe(_e(()=>y),f(n=>({version:n.tag_name})),ot({})),et(r).pipe(_e(()=>y),f(n=>({stars:n.stargazers_count,forks:n.forks_count})),ot({}))).pipe(f(([n,o])=>H(H({},n),o)))}else{let r=`https://api.github.com/users/${e}`;return et(r).pipe(f(n=>({repositories:n.public_repos})),ot({}))}}function Js(e,t){let r=`https://${e}/api/v4/projects/${encodeURIComponent(t)}`;return $t(et(`${r}/releases/permalink/latest`).pipe(_e(()=>y),f(({tag_name:n})=>({version:n})),ot({})),et(r).pipe(_e(()=>y),f(({star_count:n,forks_count:o})=>({stars:n,forks:o})),ot({}))).pipe(f(([n,o])=>H(H({},n),o)))}function Xs(e){let t=e.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i);if(t){let[,r,n]=t;return Gs(r,n)}if(t=e.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i),t){let[,r,n]=t;return Js(r,n)}return y}var xp;function wp(e){return xp||(xp=j(()=>{let t=__md_get("__source",sessionStorage);if(t)return Y(t);if(Te("consent").length){let n=__md_get("__consent");if(!(n&&n.github))return y}return Xs(e.href).pipe($(n=>__md_set("__source",n,sessionStorage)))}).pipe(_e(()=>y),L(t=>Object.keys(t).length>0),f(t=>({facts:t})),se(1)))}function Zs(e){let t=G(":scope > :last-child",e);return j(()=>{let r=new I;return r.subscribe(({facts:n})=>{t.appendChild(bs(n)),t.classList.add("md-source__repository--active")}),wp(e).pipe($(n=>r.next(n)),V(()=>r.complete()),f(n=>H({ref:e},n)))})}function Ep(e,{viewport$:t,header$:r}){return Re(document.body).pipe(g(()=>Sn(e,{header$:r,viewport$:t})),f(({offset:{y:n}})=>({hidden:n>=10})),fe("hidden"))}function Qs(e,t){return j(()=>{let r=new I;return r.subscribe({next({hidden:n}){e.hidden=n},complete(){e.hidden=!1}}),(X("navigation.tabs.sticky")?Y({hidden:!1}):Ep(e,t)).pipe($(n=>r.next(n)),V(()=>r.complete()),f(n=>H({ref:e},n)))})}function Tp(e,{viewport$:t,header$:r}){let n=new Map,o=P(".md-nav__link",e);for(let s of o){let c=decodeURIComponent(s.hash.substring(1)),l=we(`[id="${c}"]`);typeof l!="undefined"&&n.set(s,l)}let i=r.pipe(fe("height"),f(({height:s})=>{let c=ht("main"),l=G(":scope > :first-child",c);return s+.9*(l.offsetTop-c.offsetTop)}),xe());return Re(document.body).pipe(fe("height"),g(s=>j(()=>{let c=[];return Y([...n].reduce((l,[u,p])=>{for(;c.length&&n.get(c[c.length-1]).tagName>=p.tagName;)c.pop();let d=p.offsetTop;for(;!d&&p.parentElement;)p=p.parentElement,d=p.offsetTop;let m=p.offsetParent;for(;m;m=m.offsetParent)d+=m.offsetTop;return l.set([...c=[...c,u]].reverse(),d)},new Map))}).pipe(f(c=>new Map([...c].sort(([,l],[,u])=>l-u))),Ze(i),g(([c,l])=>t.pipe(Or(([u,p],{offset:{y:d},size:m})=>{let h=d+m.height>=Math.floor(s.height);for(;p.length;){let[,v]=p[0];if(v-l=d&&!h)p=[u.pop(),...p];else break}return[u,p]},[[],[...c]]),ie((u,p)=>u[0]===p[0]&&u[1]===p[1])))))).pipe(f(([s,c])=>({prev:s.map(([l])=>l),next:c.map(([l])=>l)})),J({prev:[],next:[]}),Pt(2,1),f(([s,c])=>s.prev.length{let i=new I,a=i.pipe(he(),ye(!0));i.subscribe(({prev:c,next:l})=>{for(let[u]of l)u.classList.remove("md-nav__link--passed"),u.classList.remove("md-nav__link--active");for(let[u,[p]]of c.entries())p.classList.add("md-nav__link--passed"),p.classList.toggle("md-nav__link--active",u===c.length-1)});let s=we(".md-sidebar--secondary");if(typeof s!="undefined"&&b(document.body,"click").subscribe(c=>{let l=c.target;if(!s.contains(l)){let u=we(".md-nav__toggle",s);typeof u!="undefined"&&(u.checked=!1)}}),X("toc.follow")){let c=R(t.pipe(Be(1),f(()=>{})),t.pipe(Be(250),f(()=>"smooth")));i.pipe(L(({prev:l})=>l.length>0),Ze(n.pipe(Ie(ge))),pe(c)).subscribe(([[{prev:l}],u])=>{let[p]=l[l.length-1];if(p.offsetHeight){let d=ki(p);if(typeof d!="undefined"){let m=p.offsetTop-d.offsetTop,{height:h}=Ae(d);d.scrollTo({top:m-h/2,behavior:u})}}})}return X("navigation.tracking")&&t.pipe(Q(a),fe("offset"),Be(250),ke(1),Q(o.pipe(ke(1))),jt({delay:250}),pe(i)).subscribe(([,{prev:c}])=>{let l=Ye(),u=c[c.length-1];if(u&&u.length){let[p]=u,{hash:d}=new URL(p.href);l.hash!==d&&(l.hash=d,history.replaceState({},"",`${l}`))}else l.hash="",history.replaceState({},"",`${l}`)}),Tp(e,{viewport$:t,header$:r}).pipe($(c=>i.next(c)),V(()=>i.complete()),f(c=>H({ref:e},c)))})}function Sp(e,{viewport$:t,main$:r,target$:n}){let o=t.pipe(f(({offset:{y:a}})=>a),Pt(2,1),f(([a,s])=>a>s&&s>0),ie()),i=r.pipe(f(({active:a})=>a));return re([i,o]).pipe(f(([a,s])=>!(a&&s)),ie(),Q(n.pipe(ke(1))),ye(!0),jt({delay:250}),f(a=>({hidden:a})))}function tc(e,{viewport$:t,header$:r,main$:n,target$:o}){let i=new I,a=i.pipe(he(),ye(!0));return i.subscribe({next({hidden:s}){e.hidden=s,s?(e.setAttribute("tabindex","-1"),e.blur()):e.removeAttribute("tabindex")},complete(){e.style.top="",e.hidden=!0,e.removeAttribute("tabindex")}}),r.pipe(Q(a),fe("height")).subscribe(({height:s})=>{e.style.top=`${s+16}px`}),b(e,"click").subscribe(s=>{s.preventDefault(),window.scrollTo({top:0})}),Sp(e,{viewport$:t,main$:n,target$:o}).pipe($(s=>i.next(s)),V(()=>i.complete()),f(s=>H({ref:e},s)))}function rc(e,t){return e.protocol=t.protocol,e.hostname=t.hostname,t.port&&(e.port=t.port),e}function Op(e,t){let r=new Map;for(let n of P("url",e)){let o=G("loc",n),i=[rc(new URL(o.textContent),t)];r.set(`${i[0]}`,i);for(let a of P("[rel=alternate]",n)){let s=a.getAttribute("href");s!=null&&i.push(rc(new URL(s),t))}}return r}function dr(e){return ns(new URL("sitemap.xml",e)).pipe(f(t=>Op(t,new URL(e))),_e(()=>Y(new Map)),xe())}function nc({document$:e}){let t=new Map;e.pipe(g(()=>P("link[rel=alternate]")),f(r=>new URL(r.href)),L(r=>!t.has(r.toString())),oe(r=>dr(r).pipe(f(n=>[r,n]),_e(()=>y)))).subscribe(([r,n])=>{t.set(r.toString().replace(/\/$/,""),n)}),b(document.body,"click").pipe(L(r=>!r.metaKey&&!r.ctrlKey),g(r=>{if(r.target instanceof Element){let n=r.target.closest("a");if(n&&!n.target){let o=[...t].find(([p])=>n.href.startsWith(`${p}/`));if(typeof o=="undefined")return y;let[i,a]=o,s=Ye();if(s.href.startsWith(i))return y;let c=Ue(),l=s.href.replace(c.base,"");l=`${i}/${l}`;let u=a.has(l.split("#")[0])?new URL(l,c.base):new URL(i);return r.preventDefault(),Y(u)}}return y})).subscribe(r=>dt(r,!0))}var Co=_r(Mo());function Lp(e){e.setAttribute("data-md-copying","");let t=e.closest("[data-copy]"),r=t?t.getAttribute("data-copy"):e.innerText;return e.removeAttribute("data-md-copying"),r.trimEnd()}function oc({alert$:e}){Co.default.isSupported()&&new U(t=>{new Co.default("[data-clipboard-target], [data-clipboard-text]",{text:r=>r.getAttribute("data-clipboard-text")||Lp(G(r.getAttribute("data-clipboard-target")))}).on("success",r=>t.next(r))}).pipe($(t=>{t.trigger.focus()}),f(()=>Bt("clipboard.copied"))).subscribe(e)}function ic(e,t){if(!(e.target instanceof Element))return y;let r=e.target.closest("a");if(r===null)return y;if(r.target||e.metaKey||e.ctrlKey)return y;let n=new URL(r.href);return n.search=n.hash="",t.has(`${n}`)?(e.preventDefault(),Y(r)):y}function ac(e){let t=new Map;for(let r of P(":scope > *",e.head))t.set(r.outerHTML,r);return t}function sc(e){for(let t of P("[href], [src]",e))for(let r of["href","src"]){let n=t.getAttribute(r);if(n&&!/^(?:[a-z]+:)?\/\//i.test(n)){t[r]=t[r];break}}return Y(e)}function Mp(e){for(let n of["[data-md-component=announce]","[data-md-component=container]","[data-md-component=header-topic]","[data-md-component=outdated]","[data-md-component=logo]","[data-md-component=skip]",...X("navigation.tabs.sticky")?["[data-md-component=tabs]"]:[]]){let o=we(n),i=we(n,e);typeof o!="undefined"&&typeof i!="undefined"&&o.replaceWith(i)}let t=ac(document);for(let[n,o]of ac(e))t.has(n)?t.delete(n):document.head.appendChild(o);for(let n of t.values()){let o=n.getAttribute("name");o!=="theme-color"&&o!=="color-scheme"&&n.remove()}let r=ht("container");return nt(P("script",r)).pipe(g(n=>{let o=e.createElement("script");if(n.src){for(let i of n.getAttributeNames())o.setAttribute(i,n.getAttribute(i));return n.replaceWith(o),new U(i=>{o.onload=()=>i.complete()})}else return o.textContent=n.textContent,n.replaceWith(o),y}),he(),ye(document))}function cc({sitemap$:e,location$:t,viewport$:r,progress$:n}){if(location.protocol==="file:")return Ke;Y(document).subscribe(sc);let o=b(document.body,"click").pipe(Ze(e),g(([s,c])=>ic(s,c)),f(({href:s})=>new URL(s)),xe()),i=b(window,"popstate").pipe(f(Ye),xe());o.pipe(pe(r)).subscribe(([s,{offset:c}])=>{history.replaceState(c,""),history.pushState(null,"",s)}),R(o,i).subscribe(t);let a=t.pipe(fe("pathname"),g(s=>En(s,{progress$:n}).pipe(_e(()=>(dt(s,!0),y)))),g(sc),g(Mp),xe());return R(a.pipe(pe(t,(s,c)=>c)),a.pipe(g(()=>t),fe("hash")),t.pipe(ie((s,c)=>s.pathname===c.pathname&&s.hash===c.hash),g(()=>o),$(()=>history.back()))).subscribe(s=>{var c,l;history.state!==null||!s.hash?window.scrollTo(0,(l=(c=history.state)==null?void 0:c.y)!=null?l:0):(history.scrollRestoration="auto",es(s.hash),history.scrollRestoration="manual")}),t.subscribe(()=>{history.scrollRestoration="manual"}),b(window,"beforeunload").subscribe(()=>{history.scrollRestoration="auto"}),r.pipe(fe("offset"),Be(100)).subscribe(({offset:s})=>{history.replaceState(s,"")}),X("navigation.instant.prefetch")&&R(b(document.body,"mousemove"),b(document.body,"focusin")).pipe(Ze(e),g(([s,c])=>ic(s,c)),Be(25),Yn(({href:s})=>s),cn(s=>{let c=document.createElement("link");return c.rel="prefetch",c.href=s.toString(),document.head.appendChild(c),b(c,"load").pipe(f(()=>c),Me(1))})).subscribe(s=>s.remove()),a}function lc(e){var u;let{selectedVersionSitemap:t,selectedVersionBaseURL:r,currentLocation:n,currentBaseURL:o}=e,i=(u=Ho(o))==null?void 0:u.pathname;if(i===void 0)return;let a=kp(n.pathname,i);if(a===void 0)return;let s=Cp(t.keys());if(!t.has(s))return;let c=Ho(a,s);if(!c||!t.has(c.href))return;let l=Ho(a,r);if(l)return l.hash=n.hash,l.search=n.search,l}function Ho(e,t){try{return new URL(e,t)}catch(r){return}}function kp(e,t){if(e.startsWith(t))return e.slice(t.length)}function Ap(e,t){let r=Math.min(e.length,t.length),n;for(n=0;ny)),n=r.pipe(f(o=>{let[,i]=t.base.match(/([^/]+)\/?$/);return o.find(({version:a,aliases:s})=>a===i||s.includes(i))||o[0]}));r.pipe(f(o=>new Map(o.map(i=>[`${new URL(`../${i.version}/`,t.base)}`,i]))),g(o=>b(document.body,"click").pipe(L(i=>!i.metaKey&&!i.ctrlKey),pe(n),g(([i,a])=>{if(i.target instanceof Element){let s=i.target.closest("a");if(s&&!s.target&&o.has(s.href)){let c=s.href;return!i.target.closest(".md-version")&&o.get(c)===a?y:(i.preventDefault(),Y(new URL(c)))}}return y}),g(i=>dr(i).pipe(f(a=>{var s;return(s=lc({selectedVersionSitemap:a,selectedVersionBaseURL:i,currentLocation:Ye(),currentBaseURL:t.base}))!=null?s:i})))))).subscribe(o=>dt(o,!0)),re([r,n]).subscribe(([o,i])=>{G(".md-header__topic").appendChild(_s(o,i))}),e.pipe(g(()=>n)).subscribe(o=>{var s;let i=new URL(t.base),a=__md_get("__outdated",sessionStorage,i);if(a===null){a=!0;let c=((s=t.version)==null?void 0:s.default)||"latest";Array.isArray(c)||(c=[c]);e:for(let l of c)for(let u of o.aliases.concat(o.version))if(new RegExp(l,"i").test(u)){a=!1;break e}__md_set("__outdated",a,sessionStorage,i)}if(a)for(let c of Te("outdated"))c.hidden=!1})}function pc({document$:e,viewport$:t}){e.pipe(g(()=>P(".md-ellipsis")),oe(r=>Et(r).pipe(Q(e.pipe(ke(1))),L(n=>n),f(()=>r),Me(1))),L(r=>r.offsetWidth{let n=r.innerText,o=r.closest("a")||r;return o.title=n,X("content.tooltips")?Ge(o,{viewport$:t}).pipe(Q(e.pipe(ke(1))),V(()=>o.removeAttribute("title"))):y})).subscribe(),X("content.tooltips")&&e.pipe(g(()=>P(".md-status")),oe(r=>Ge(r,{viewport$:t}))).subscribe()}function fc({document$:e,tablet$:t}){e.pipe(g(()=>P(".md-toggle--indeterminate")),$(r=>{r.indeterminate=!0,r.checked=!1}),oe(r=>b(r,"change").pipe(Xn(()=>r.classList.contains("md-toggle--indeterminate")),f(()=>r))),pe(t)).subscribe(([r,n])=>{r.classList.remove("md-toggle--indeterminate"),n&&(r.checked=!1)})}function Hp(){return/(iPad|iPhone|iPod)/.test(navigator.userAgent)}function mc({document$:e}){e.pipe(g(()=>P("[data-md-scrollfix]")),$(t=>t.removeAttribute("data-md-scrollfix")),L(Hp),oe(t=>b(t,"touchstart").pipe(f(()=>t)))).subscribe(t=>{let r=t.scrollTop;r===0?t.scrollTop=1:r+t.offsetHeight===t.scrollHeight&&(t.scrollTop=r-1)})}Object.entries||(Object.entries=function(e){let t=[];for(let r of Object.keys(e))t.push([r,e[r]]);return t});Object.values||(Object.values=function(e){let t=[];for(let r of Object.keys(e))t.push(e[r]);return t});typeof Element!="undefined"&&(Element.prototype.scrollTo||(Element.prototype.scrollTo=function(e,t){typeof e=="object"?(this.scrollLeft=e.left,this.scrollTop=e.top):(this.scrollLeft=e,this.scrollTop=t)}),Element.prototype.replaceWith||(Element.prototype.replaceWith=function(...e){let t=this.parentNode;if(t){e.length===0&&t.removeChild(this);for(let r=e.length-1;r>=0;r--){let n=e[r];typeof n=="string"?n=document.createTextNode(n):n.parentNode&&n.parentNode.removeChild(n),r?t.insertBefore(this.previousSibling,n):t.replaceChild(n,this)}}}));function $p(){return location.protocol==="file:"?ar(`${new URL("search.js",Mn.base)}`).pipe(f(()=>__index),_e(()=>Ke),se(1)):et(new URL("search.json",Mn.base))}document.documentElement.classList.remove("no-js");document.documentElement.classList.add("js");var vt=Si(),Ur=Za(),hr=ts(Ur),hc=Xa(),ze=cs(),$o=Ir("(min-width: 60em)"),vc=Ir("(min-width: 76.25em)"),bc=rs(),Mn=Ue(),gc=we(".md-search")?$p():Ke,Po=new I;oc({alert$:Po});nc({document$:vt});var Io=new I,_c=dr(Mn.base);X("navigation.instant")&&cc({sitemap$:_c,location$:Ur,viewport$:ze,progress$:Io}).subscribe(vt);var dc;((dc=Mn.version)==null?void 0:dc.provider)==="mike"&&uc({document$:vt});R(Ur,hr).pipe(It(125)).subscribe(()=>{Eo("drawer",!1),Eo("search",!1)});hc.pipe(L(({mode:e,meta:t})=>e==="global"&&!t)).subscribe(e=>{switch(e.type){case",":case"p":let t=document.querySelector("link[rel=prev]");t instanceof HTMLLinkElement&&dt(t);break;case".":case"n":let r=document.querySelector("link[rel=next]");r instanceof HTMLLinkElement&&dt(r);break;case"/":let n=document.querySelector("[data-md-component=search] button");n instanceof HTMLButtonElement&&n.click();break;case"Enter":let o=xt();o instanceof HTMLLabelElement&&o.click()}});pc({viewport$:ze,document$:vt});fc({document$:vt,tablet$:$o});mc({document$:vt});var Lt=Us(ht("header"),{viewport$:ze}),Fr=vt.pipe(f(()=>ht("main")),g(e=>Ws(e,{viewport$:ze,header$:Lt})),se(1)),Pp=R(...Te("consent").map(e=>us(e,{target$:hr})),...Te("dialog").map(e=>Fs(e,{alert$:Po})),...Te("palette").map(e=>Vs(e)),...Te("progress").map(e=>zs(e,{progress$:Io})),...Te("search").map(e=>Ks(e,{index$:gc})),...Te("source").map(e=>Zs(e))),Ip=j(()=>R(...Te("announce").map(e=>ls(e)),...Te("content").map(e=>js(e,{sitemap$:_c,viewport$:ze,target$:hr,print$:bc})),...Te("content").map(e=>X("search.highlight")?Ys(e,{index$:gc,location$:Ur}):y),...Te("header").map(e=>Ns(e,{viewport$:ze,header$:Lt,main$:Fr})),...Te("header-title").map(e=>Ds(e,{viewport$:ze,header$:Lt})),...Te("sidebar").map(e=>e.getAttribute("data-md-type")==="navigation"?yo(vc,()=>Ao(e,{viewport$:ze,header$:Lt,main$:Fr})):yo($o,()=>Ao(e,{viewport$:ze,header$:Lt,main$:Fr}))),...Te("tabs").map(e=>Qs(e,{viewport$:ze,header$:Lt})),...Te("toc").map(e=>ec(e,{viewport$:ze,header$:Lt,main$:Fr,target$:hr})),...Te("top").map(e=>tc(e,{viewport$:ze,header$:Lt,main$:Fr,target$:hr})))),yc=vt.pipe(g(()=>Ip),Rt(Pp),se(1));yc.subscribe();window.document$=vt;window.location$=Ur;window.target$=hr;window.keyboard$=hc;window.viewport$=ze;window.tablet$=$o;window.screen$=vc;window.print$=bc;window.alert$=Po;window.progress$=Io;window.component$=yc;})(); diff --git a/docs/site/assets/javascripts/workers/search.e2d2d235.min.js b/docs/site/assets/javascripts/workers/search.e2d2d235.min.js new file mode 100644 index 0000000..a56d589 --- /dev/null +++ b/docs/site/assets/javascripts/workers/search.e2d2d235.min.js @@ -0,0 +1 @@ +"use strict";(()=>{var vt=Object.create;var K=Object.defineProperty,wt=Object.defineProperties,bt=Object.getOwnPropertyDescriptor,Tt=Object.getOwnPropertyDescriptors,Mt=Object.getOwnPropertyNames,W=Object.getOwnPropertySymbols,kt=Object.getPrototypeOf,Y=Object.prototype.hasOwnProperty,Et=Object.prototype.propertyIsEnumerable;var B=(t,e,n)=>e in t?K(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n,R=(t,e)=>{for(var n in e||(e={}))Y.call(e,n)&&B(t,n,e[n]);if(W)for(var n of W(e))Et.call(e,n)&&B(t,n,e[n]);return t},Q=(t,e)=>wt(t,Tt(e));var Ft=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports);var Rt=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let l of Mt(e))!Y.call(t,l)&&l!==n&&K(t,l,{get:()=>e[l],enumerable:!(r=bt(e,l))||r.enumerable});return t};var qt=(t,e,n)=>(n=t!=null?vt(kt(t)):{},Rt(e||!t||!t.__esModule?K(n,"default",{value:t,enumerable:!0}):n,t));var L=(t,e,n)=>B(t,typeof e!="symbol"?e+"":e,n);var E=(t,e,n)=>new Promise((r,l)=>{var o=u=>{try{s(n.next(u))}catch(i){l(i)}},a=u=>{try{s(n.throw(u))}catch(i){l(i)}},s=u=>u.done?r(u.value):Promise.resolve(u.value).then(o,a);s((n=n.apply(t,e)).next())});var xt=Ft(mt=>{"use strict";function C(t,e,n={}){return{name:t,from:e,meta:n}}function H(t,e){let n=[{value:t,depth:0}];for(let r=0,l=-1,o=0;r>=0;){let{value:a,depth:s}=n[r];if(l<=s&&a.type==="operator"&&a.data.operands.length>0)for(let u=a.data.operands.length;u>0;)n[++r]={value:a.data.operands[--u],depth:s+1};else{let u=e(a,o++,s);if(typeof u<"u")return u;--r}l=s}}var P=class extends Error{constructor(t,e){super(e),this.code=t}};function $(t,e){let n=zt(t);for(let r=0;r{let{matches:r}=n;for(let l=0;l{let l=e.get(n);return typeof l>"u"&&e.set(n,l=t(n,...r)),l}}function D(t,e){return Object.defineProperty(e,"name",{value:t}),e}function tt(t){return E(this,null,function*(){let e=[];if(typeof t.plugins<"u")for(let n=0;n32)throw new RangeError("Bit format exceeds 32 bits");return t}function nt(t,e,n){let r=N(t),l=N(e),o=typeof n<"u"?N(n):32-r-l;return St({d:r,f:l,x:o})}var T=[0];for(let t=0;t<32;t++)T.push(T[t]|1<=n&&t{e+=r*r}),Math.sqrt(e)}function Pt(t,e){t instanceof J?t.data.forEach((n,r)=>{e(n,r)}):t.forEach((n,r)=>{e({start:n,end:n+1,value:1},r)})}var O=class{constructor(t,e=jt(Math.ceil(t/32))){this.size=t,this.data=e}get(t){return this.data[t>>>5]>>>t&1}set(t){this.data[t>>>5]|=1<<(t&31)}forEach(t){let e=this.size&31;for(let n=0;n>>0;for(let n=0;n0;l++){let{value:o,depth:a}=n[--r],s=e(o,l,a);if(typeof s<"u")return s;for(let u=o.children.length;u>0;)n[r++]={value:o.children[--u],depth:a+1}}}function Vt(t,e){return E(this,null,function*(){let{fields:n,plugins:r=[]}=e,l=nt(t.length,n.length),o=[];for(let u=0;u"u")continue;let f=u<{var m;return(m=g.onFilterInput)==null?void 0:m.call(g,p,f,l)},d);let c=o[i],h=k();d=Array.isArray(d)?d:[d];for(let p=0;p{let v=c.index.get(m.node);typeof v>"u"&&c.index.set(m.node,v=k());let w=c.terms.length;for(let b=0;b{var i;return(i=u.onFilterStore)==null?void 0:i.call(u,s,e,t)}),s})}function Ut(t,e,n,r={}){let l=[];if(e<0||e>=t.count.fields)return l;let o=t.shards[e],a=new Map,{count:s=1/0,depth:u=1/0}=r;for(let i=0;iu)continue;let p=a.get(d);typeof p>"u"&&a.set(d,p={node:f,children:[]});let g=l;h>0&&(g=a.get(o.terms[c]).children),g.length=t.count.fields)return{documents:r,terms:l};let o=t.shards[n];return e.forEach(a=>{let{occurrences:s}=o.terms[a];for(let u=0;u>>t.space.x>>>t.space.f;r.set(i)}l[n].set(a)}),{documents:r,terms:l}}function Bt(t){let{documents:e,terms:n}=U(t);A(e,1);for(let r=0;rnew O(e.length))}}function Kt(t,e,n){let{compiler:r,fields:l,plugins:o=[]}=n,{input:a,scope:s,abort:u=!1}=z(o,(f,c)=>{var h;return(h=c.onFilterQuery)==null?void 0:h.call(c,f,t,n)},e),i={items:[],query:{select:U(t),values:[]}},d=new Map;if(u===!1){let f=r(n),{select:c,values:h}=f(a,t);typeof s<"u"&&V(c.documents,s);let p=new Map;i.query={select:c,values:h},c.terms.forEach((g,m)=>{g.forEach(y=>{let x=t.shards[m],{occurrences:v}=x.terms[y];for(let w=0;w>>t.space.x,F=b>>>t.space.f;if(!c.documents.get(F))continue;let S=p.get(b);typeof S>"u"&&p.set(b,S=new j(k()));let yt=M&T[t.space.x];S.add(yt,y)}})}),c.documents.forEach(g=>{let m={id:g,matches:[]};i.items.push(m),d.set(g,m)}),p.forEach((g,m)=>{let y=m>>>t.space.f,x=m&T[t.space.f];d.get(y).matches.push({id:m,field:l[x].name,value:{filter:g},score:0})})}return z(o,(f,c)=>{var h;return(h=c.onFilterResult)==null?void 0:h.call(c,f,t,n)},i)}function st(t){let{fields:e}=t;return(n,r)=>{if(It(n))return n;let l=[Bt(r)],o=[],a=0;return H(n,({type:s,data:u})=>{switch(s){case"value":let i=e.findIndex(({name:c})=>c===u.field);if(i===-1){l[a++]=U(r);break}let d=u.value;if(typeof d!="object"){let c=new j(k()),h=r.shards[i],p=h.index.get(d);if(typeof p<"u")for(let g=0;gf+1&&a--;){I(l[f].documents,l[a].documents);for(let c=0;cf+1&&a--;){V(l[f].documents,l[a].documents);for(let c=0;cf+1&&a--;)lt(l[f].documents,l[a].documents)}}}),{select:l[0],values:o}}}function Lt(t){return{name:t.name,data:t.data,onFilterOptions:t.onFilterOptions,onFilterInput:t.onFilterInput,onFilterStore:t.onFilterStore,onFilterQuery:t.onFilterQuery,onFilterResult:t.onFilterResult}}function ot(t){return typeof t=="object"&&t!==null&&"type"in t&&"data"in t}function Nt(t){return typeof t=="object"&&t!==null&&"select"in t&&"values"in t}function Gt(t){return t.normalize("NFKD").toLowerCase()}function Ht(t,e){let n=Math.min(t.length,e.length);for(let r=0;r65535)){let o=e(l=t.codePointAt(n),n);if(typeof o<"u")return o}}function ut(t,e,n=0,r=t.length){let l=k();return Jt(t,o=>{l.push(o);let a=e(String.fromCodePoint(...l),l.length);if(typeof a<"u")return a},n,r)}function Wt(t,e,n=0,r=t.length){let l=n;for(let o=0;ln&&e(n,n=l);continue;case 62:n=l+1}l>n&&e(n,l)}function it(t,e,n,r=0){return Wt(t,(l,o)=>e(t,(a,s)=>{r=n({value:t.slice(a,s),index:r,start:a,end:s})},l,o)),r}function Yt(t,e,n,r=0){for(let l=0,o=0;l(a.start+=o,a.end+=o,n(a)),r);return r}function Zt(t){let e=new RegExp(t,"gu");return(n,r,l=0,o=n.length)=>{var u;e.lastIndex=l;let a,s=0;do{a=e.exec(n);let i=(u=a==null?void 0:a.index)!=null?u:o;l"u")continue;let p=f<{var y;return(y=m.onTextInput)==null?void 0:y.call(m,g,p,a)},h),h=Array.isArray(h)?h:[h],Yt(h,n,g=>{let m=z(o,(y,x)=>{var v;return(v=x.onTextTokens)==null?void 0:v.call(x,y)},[g]);for(let y=0;y"u"?s.set(x,[p<{var c;return(c=f.onTextStore)==null?void 0:c.call(f,i,e,t)}),i})}function Xt(t,e,n){let{documents:r,terms:l}=_(t);return n<0||n>=t.count.fields?{documents:r,terms:l}:(e.forEach(o=>{let{occurrences:a}=t.terms[o];for(let s=0;s>>t.space.x;if((u&T[t.space.f])!==n)continue;let i=u>>>t.space.f;r.set(i)}l.set(o)}),{documents:r,terms:l})}function te(t,e){let{documents:n,terms:r}=_(t),l=t.space.f+t.space.x;return e.forEach(o=>{let{occurrences:a}=t.terms[o];for(let s=0;s>>l);r.set(o)}),{documents:n,terms:r}}function _(t){return{documents:new O(t.count.documents),terms:new O(t.terms.length)}}function ee(t,e,n){let{compiler:r,fields:l,plugins:o=[]}=n,{input:a,scope:s,abort:u=!1}=z(o,(f,c)=>{var h;return(h=c.onTextQuery)==null?void 0:h.call(c,f,t,n)},e),i={items:[],query:{select:_(t),values:[]}},d=new Map;if(u===!1){let f=r(n),{select:c,values:h}=f(a,t);typeof s<"u"&&V(c.documents,s);let p=new O(l.length),g=new Map;i.query={select:c,values:h},c.terms.forEach(m=>{A(p,0);for(let x=0;x>>t.space.x,M=w>>>t.space.f;if(!c.documents.get(M))continue;let b=w&T[t.space.f];if(!p.get(b))continue;let F=g.get(w);typeof F>"u"&&g.set(w,F=new j(k()));let S=v&T[t.space.x];F.add(S,m)}}),c.documents.forEach(m=>{let y={id:m,matches:[]};i.items.push(y),d.set(m,y)}),g.forEach((m,y)=>{let x=y>>>t.space.f,v=y&T[t.space.f];d.get(x).matches.push({id:y,field:l[v].name,value:{text:m},score:0})})}return z(o,(f,c)=>{var h;return(h=c.onTextResult)==null?void 0:h.call(c,f,t,n)},i)}function ne(t,e=10){return t.length>1?1+t[t.length-1]-t[0]:e}function re(t,e,n,r=10){let l=[];t.value.text.forEach((s,u)=>{for(let i=0;is.index-u.index);let o=l.slice(0,1),a=0;for(let s=0;sr||i.value===u.value)d=o.map(({index:f})=>f),o=[l[s+1]];else{for(let f=0;fi.index-u.index){let h=o.splice(f+1);d=o.map(({index:p})=>p),o=[...h,l[s+1]]}else d=o.map(({index:h})=>h),o=[l[s+1]];break}}typeof d>"u"&&o.push(l[s+1])}if(typeof d<"u"){let f=n(d,a++);if(typeof f<"u")return f}}if(o.length)return n(o.map(({index:s})=>s),a)}function le(t){let{transform:e,parser:n,fields:r}=t,l=n(t);return(o,a)=>{if(Nt(o))return o;typeof o=="string"&&(o=l(o));let s=[_(a)],u=[],i=0;return H(o,({type:d,data:f})=>{switch(d){case"value":let c=f.value;if(typeof c=="string"){let p=new j(k()),g=a.index.get(e(c));typeof g<"u"&&p.add(g,1),c=p}if(f.field==="*")s[i++]=te(a,c);else{let p=r.findIndex(({name:g})=>g===f.field);s[i++]=Xt(a,c,p)}u.push(Q(R({},f),{value:c}));break;case"operator":let h=i-f.operands.length;switch(f.operator){case"or":for(;i>h+1&&i--;)I(s[h].documents,s[i].documents),I(s[h].terms,s[i].terms);break;case"and":for(;i>h+1&&i--;)V(s[h].documents,s[i].documents),I(s[h].terms,s[i].terms);break;case"not":for(at(s[h].documents),A(s[h].terms,0);i>h+1&&i--;)lt(s[h].documents,s[i].documents)}}}),{select:s[0],values:u}}}function ft(t,e){return H(t,(n,r,l)=>{if(n.type!=="value")return;let o=e(n.data,r,l);if(typeof o<"u")return o})}function ct(t){if(t.length===0)return[];let e=[],n=[];for(let o=0;oo.index-a.index);let r=new Set([n[0].value]),l=n[0].index;for(let o=1;o{t[i].start>l||t[i].end{e.push({start:l,end:o,value:n})})}return new J(ct(e))}function ae(t,e="or",n){let{separator:r}=t;return n!=null||(n=l=>({field:"*",value:l.value})),l=>{let o=[];return it(l,r,a=>{let s=n(a);typeof s<"u"&&o.push({type:"value",data:s})}),{type:"operator",data:{operator:e,operands:o}}}}function se(t,e){return E(this,null,function*(){let n=yield tt(e),r=yield At(n,(o,a)=>{var s;return(s=a.onTextOptions)==null?void 0:s.call(a,o,t)},Q(R({},e),{plugins:n})),l=yield $t(t,r);return D("text",o=>{if(o.type!=="text")throw new P("unsupported");return{type:o.type,data:ee(l,o.data,r)}})})}function q(t){return{name:t.name,data:t.data,onTextOptions:t.onTextOptions,onTextInput:t.onTextInput,onTextTokens:t.onTextTokens,onTextStore:t.onTextStore,onTextQuery:t.onTextQuery,onTextResult:t.onTextResult}}function oe(t){let{handlers:e}=t,n,r=new Map;return Lt({name:"aggregation",onFilterStore(l,o){for(let a=0;a"u")continue;let u=!0;o.documents.forEach(i=>{u=!1}),u&&A(o.documents,1),l.aggregations.push(s(a,o))}}})}function ue(t={}){let{empty:e=!1,limit:n}=t;return(r,{fields:l})=>{let o=r.space.f+r.space.x;return D("term",({type:a,data:s},{documents:u})=>{if(a!=="term")throw new P("unsupported");let i=l.findIndex(({name:f})=>f===s.field),d=Ut(r,i,f=>{let c=0,{occurrences:h}=f;for(let p=0;p>>o)&&c++;if(!(e===!1&&c===0))return{value:f.value,count:c}},R(R({},n),s.limit));return{type:a,data:{field:s.field,value:d}}})}}function ie(t,e="prefix"){return{type:e,data:t}}function fe(t){return typeof t=="object"&&"type"in t&&typeof t.type=="string"&&"data"in t&&typeof t.data=="string"}function ce(t,e={}){var u;let{prefix:n=2,filter:r=[]}=e,l=t.terms,o=new Map,a=Ot(l.length),s=k();for(let i=0;i{var p;return o.set(c,(p=o.get(c))!=null?p:i),h===n||void 0});let f=i?l[i-1]:"";a[i]=Ht(f,d)}for(let i=0;ii-d),{terms:l,index:o,cover:a,exact:s}}function de(t,e){let n="",r=-1,l=-1;if(ut(e,s=>{let u=t.index.get(s);if(typeof u>"u")return!0;n=s,r=u}),r!==-1)for(let s=n.length;ss>r&&sa),index:e.index},{prefix:t.prefix,filter:(l=t.filter)==null?void 0:l.map(r)}))},onTextQuery(e,n,r){let{transform:l,parser:o}=r;if(typeof e.input=="string")e.input=o(r)(e.input);else if(!ot(e.input))return;ft(e.input,a=>{var u;let s=a.value;if(fe(s))s=l(s.data);else return;a.value=(u=de(this.data,s))!=null?u:s})}})}function pe(t){let e=Q(R({},t),{plugins:[]}),n,r,l;return q({name:"filter",onTextOptions(a,s){return E(this,null,function*(){e.plugins=yield tt(t),l=yield Vt(s,e)})},onTextQuery(a){typeof a.filter<"u"&&(n=a.filter,r!=null||(r=st(e)),n.input=r(n.input,l),a.scope=n.input.select.documents)},onTextResult(a){if(typeof n<"u"){let s=!0;a.query.select.documents.forEach(i=>{s=!1}),s||(n.scope=a.query.select.documents);let u=Kt(l,n,e);a.aggregations=u.aggregations,n=void 0}}})}function ht(t,e){let n=[],r=t/e>>>0,l=t%e,o=0;if(r)for(let a=0;as);r.sort((a,s)=>t.terms[a].length-t.terms[s].length||t.terms[a].localeCompare(t.terms[s]));let l=0;for(let a=0;al&&(l=t.terms[a].length);let o=[];for(let a=0;a"u"?u[d].set(f,[r[a]]):c.push(r[a])}}return{index:o,terms:t.terms,idxmp:r}}var ye=[["id","di","rr"],["dr","rd"],["dd"]];function pt(t,e,n=2){if(t.lengthn)return;let a,s,u,i=n+1;for(let d of ye[o]){for(u=a=s=0;an)break;switch(d[u-1]){case"d":a++;break;case"i":s++;break;case"r":a++,s++;break}}else a++,s++;u+=r-a+(l-s),u"u")continue;let a=me(e,o,2);for(let s=0;s"u"))for(let c of f){let h=t.terms[c].length,p=n(t.terms[c],e);typeof p<"u"&&r.add(c,(h-p)/h)}}}}if(r.data.length)return r}function we(t={}){return q({name:"fuzzy",onTextStore(e){var n;(n=this.data)!=null||(this.data=xe({terms:e.terms.map(({value:r})=>r)},t))},onTextQuery(e,n,r){let{transform:l,parser:o}=r;if(typeof e.input=="string")e.input=o(r)(e.input);else if(!ot(e.input))return;ft(e.input,a=>{var u;let s=a.value;if(typeof s=="string")s=l(s);else return;n.index.get(s)||(a.value=(u=ve(this.data,s))!=null?u:s)})}})}function be(){return{tables:new Map}}function Te(t,e={}){let{count:n}=e;return D("term",r=>{let l=dt(r);return(o,a)=>{let s=[];return o.value.text.forEach((u,i)=>{let d=a[u]>>>10,f=a[u]&T[10];for(let p=0;pu.start-i.start),{ranges:ct(s).slice(0,n)}}})}function Me(t){let e,n;return q({name:"highlight",data:be(),onTextInput(r,l){let{tables:o}=this.data;o.set(l,n=k())},onTextTokens(r){for(let l=0;l{let s=l.get(a.id);if(a.value.highlight)return;let u=o(a,s);a.value=Q(R({},a.value),{highlight:u})})}})}function ke(){return{directives:[]}}function gt(...t){return(e,n)=>{for(let r=0;r{if(r!=="match")throw new P("unsupported");let o=Fe(t),a=gt(...e.map(s=>s(n)));return $(n,({matches:s})=>{s.sort(o)}),(s,u)=>{let i=Math.min(s.matches.length,u.matches.length);for(let d=0,f=0;dr*(l.get(a.field)-l.get(s.field))}function Re(t,e={}){let n=dt(t.query),r=G(re),l=G(ne);return(o,a)=>{let s=r(o,n,f=>f),u=r(a,n,f=>f);if(s.length!==u.length)return u.length-s.length;let i=l(s),d=l(u);return i!==d?i-d:s[0]!==u[0]?s[0]-u[0]:0}}function qe(t){let e=new Map;return q({name:"order",data:ke(),onTextOptions(r,l){return E(this,null,function*(){for(let o=0;o"u")throw new P("unknown");o.push(u(r,s))}r.items.sort(gt(...o))}})}function ze(t){let e=t.handler();return q({name:"pagination",onTextQuery(n){return e.onQuery(n,t)},onTextResult(n){return e.onResult(n,t)}})}function Ae(t){let{id:e,size:n=10,from:r=0}=t;if(r-n>=0)return{id:e,size:n,from:r-n}}function Qe(t,e){let{id:n,size:r=10,from:l=0}=t;if(l+rE(null,null,function*(){let e=t.data;switch(e.type){case 0:Z=yield se(e.data.items,{separator:Zt(e.data.config.separator),transform:G(Gt),parser:r=>ae(r,"and",l=>({field:"*",value:ie(l.value),range:{start:l.start,end:l.end,value:l.index}})),compiler:le,fields:[C("title",r=>r.title,{weight:3}),C("text",r=>r.text),C("path",r=>r.path,{weight:2})],plugins:[he(),we(),pe({compiler:st,fields:[C("tags",r=>r.tags)],plugins:[oe({handlers:[ue()]})]}),qe({handlers:[r=>Ee({fields:r.fields,comparators:[Re]})],defaults:{order:[{type:"match",data:{field:"*"}}]}}),()=>q({onTextResult(r){r.total=r.items.length}}),ze({handler:Se,size:10}),Me({handler:r=>Te(r)}),()=>q({onTextResult(r){let{query:l}=r,o=l.values.map(({range:a,value:s})=>{var i,d;let u=!1;return s.forEach((f,c)=>{!u&&c<1&&(u=!0)}),u?-1:((i=a==null?void 0:a.end)!=null?i:0)-((d=a==null?void 0:a.start)!=null?d:0)});X(r,a=>{var s;(s=a.value.highlight)==null||s.ranges.forEach(u=>{u.value=o[u.value]})})}})]}),self.postMessage({type:1});break;case 2:let n=Z({type:"text",data:e.data});self.postMessage({type:3,data:n.data});break}})});var _e=qt(xt());})(); diff --git a/docs/site/assets/stylesheets/classic/main.96fc3bb8.min.css b/docs/site/assets/stylesheets/classic/main.96fc3bb8.min.css new file mode 100644 index 0000000..d880a52 --- /dev/null +++ b/docs/site/assets/stylesheets/classic/main.96fc3bb8.min.css @@ -0,0 +1 @@ +@charset "UTF-8";html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;box-sizing:border-box}*,:after,:before{box-sizing:inherit}@media (prefers-reduced-motion){*,:after,:before{transition:none!important}}body{margin:0}a,button,input,label{-webkit-tap-highlight-color:transparent}a{color:inherit;text-decoration:none}hr{border:0;box-sizing:initial;display:block;height:.05rem;overflow:visible;padding:0}small{font-size:80%}sub,sup{line-height:1em}img{border-style:none}table{border-collapse:initial;border-spacing:0}td,th{font-weight:400;vertical-align:top}button{background:#0000;border:0;font-family:inherit;font-size:inherit;margin:0;padding:0}input{border:0;outline:none}:root{--md-primary-fg-color:#4051b5;--md-primary-fg-color--light:#5d6cc0;--md-primary-fg-color--dark:#303fa1;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3;--md-accent-fg-color:#526cfe;--md-accent-fg-color--transparent:#526cfe1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-scheme=default]{color-scheme:light}[data-md-color-scheme=default] img[src$="#gh-dark-mode-only"],[data-md-color-scheme=default] img[src$="#only-dark"]{display:none}:root,[data-md-color-scheme=default]{--md-hue:225deg;--md-default-fg-color:#000000de;--md-default-fg-color--light:#0000008a;--md-default-fg-color--lighter:#00000052;--md-default-fg-color--lightest:#00000012;--md-default-bg-color:#fff;--md-default-bg-color--light:#ffffffb3;--md-default-bg-color--lighter:#ffffff4d;--md-default-bg-color--lightest:#ffffff1f;--md-code-fg-color:#36464e;--md-code-bg-color:#f5f5f5;--md-code-bg-color--light:#f5f5f5b3;--md-code-bg-color--lighter:#f5f5f54d;--md-code-hl-color:#4287ff;--md-code-hl-color--light:#4287ff1a;--md-code-hl-number-color:#d52a2a;--md-code-hl-special-color:#db1457;--md-code-hl-function-color:#a846b9;--md-code-hl-constant-color:#6e59d9;--md-code-hl-keyword-color:#3f6ec6;--md-code-hl-string-color:#1c7d4d;--md-code-hl-name-color:var(--md-code-fg-color);--md-code-hl-operator-color:var(--md-default-fg-color--light);--md-code-hl-punctuation-color:var(--md-default-fg-color--light);--md-code-hl-comment-color:var(--md-default-fg-color--light);--md-code-hl-generic-color:var(--md-default-fg-color--light);--md-code-hl-variable-color:var(--md-default-fg-color--light);--md-typeset-color:var(--md-default-fg-color);--md-typeset-a-color:var(--md-primary-fg-color);--md-typeset-del-color:#f5503d26;--md-typeset-ins-color:#0bd57026;--md-typeset-kbd-color:#fafafa;--md-typeset-kbd-accent-color:#fff;--md-typeset-kbd-border-color:#b8b8b8;--md-typeset-mark-color:#ffff0080;--md-typeset-table-color:#0000001f;--md-typeset-table-color--light:rgba(0,0,0,.035);--md-admonition-fg-color:var(--md-default-fg-color);--md-admonition-bg-color:var(--md-default-bg-color);--md-warning-fg-color:#000000de;--md-warning-bg-color:#ff9;--md-footer-fg-color:#fff;--md-footer-fg-color--light:#ffffffb3;--md-footer-fg-color--lighter:#ffffff73;--md-footer-bg-color:#000000de;--md-footer-bg-color--dark:#00000052;--md-shadow-z1:0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a;--md-shadow-z2:0 0.2rem 0.5rem #0000001a,0 0 0.05rem #00000040;--md-shadow-z3:0 0.2rem 0.5rem #0003,0 0 0.05rem #00000059;--color-foreground:0 0 0;--color-background:255 255 255;--color-background-subtle:240 240 240;--color-backdrop:255 255 255}.md-icon svg{fill:currentcolor;display:block;height:1.2rem;width:1.2rem}.md-icon svg.lucide{fill:#0000;stroke:currentcolor}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--md-text-font-family:var(--md-text-font,_),-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;--md-code-font-family:var(--md-code-font,_),SFMono-Regular,Consolas,Menlo,monospace}aside,body,input{font-feature-settings:"kern","liga";color:var(--md-typeset-color);font-family:var(--md-text-font-family)}code,kbd,pre{font-feature-settings:"kern";font-family:var(--md-code-font-family)}:root{--md-typeset-table-sort-icon:url('data:image/svg+xml;charset=utf-8,');--md-typeset-table-sort-icon--asc:url('data:image/svg+xml;charset=utf-8,');--md-typeset-table-sort-icon--desc:url('data:image/svg+xml;charset=utf-8,')}.md-typeset{-webkit-print-color-adjust:exact;color-adjust:exact;font-size:.8rem;line-height:1.6;overflow-wrap:break-word}@media print{.md-typeset{font-size:.68rem}}.md-typeset blockquote,.md-typeset dl,.md-typeset figure,.md-typeset ol,.md-typeset pre,.md-typeset ul{margin-bottom:1em;margin-top:1em}.md-typeset h1{color:var(--md-default-fg-color--light);font-size:2em;line-height:1.3;margin:0 0 1.25em}.md-typeset h1,.md-typeset h2{font-weight:300;letter-spacing:-.01em}.md-typeset h2{font-size:1.5625em;line-height:1.4;margin:1.6em 0 .64em}.md-typeset h3{font-size:1.25em;font-weight:400;letter-spacing:-.01em;line-height:1.5;margin:1.6em 0 .8em}.md-typeset h2+h3{margin-top:.8em}.md-typeset h4{font-weight:700;letter-spacing:-.01em;margin:1em 0}.md-typeset h5,.md-typeset h6{color:var(--md-default-fg-color--light);font-size:.8em;font-weight:700;letter-spacing:-.01em;margin:1.25em 0}.md-typeset h5{text-transform:uppercase}.md-typeset h5 code{text-transform:none}.md-typeset hr{border-bottom:.05rem solid var(--md-default-fg-color--lightest);display:flow-root;margin:1.5em 0}.md-typeset a{color:var(--md-typeset-a-color);word-break:break-word}.md-typeset a,.md-typeset a:before{transition:color 125ms}.md-typeset a:focus,.md-typeset a:hover{color:var(--md-accent-fg-color)}.md-typeset a:focus code,.md-typeset a:hover code{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}.md-typeset a code{color:var(--md-typeset-a-color)}.md-typeset a.focus-visible{outline-color:var(--md-accent-fg-color);outline-offset:.2rem}.md-typeset code,.md-typeset kbd,.md-typeset pre{color:var(--md-code-fg-color);direction:ltr;font-variant-ligatures:none;transition:background-color 125ms}@media print{.md-typeset code,.md-typeset kbd,.md-typeset pre{white-space:pre-wrap}}.md-typeset code{background-color:var(--md-code-bg-color);border-radius:.1rem;-webkit-box-decoration-break:clone;box-decoration-break:clone;font-size:.85em;padding:0 .2941176471em;transition:color 125ms,background-color 125ms;word-break:break-word}.md-typeset code:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}.md-typeset pre{display:flow-root;line-height:1.4;position:relative}.md-typeset pre>code{-webkit-box-decoration-break:slice;box-decoration-break:slice;box-shadow:none;display:block;margin:0;outline-color:var(--md-accent-fg-color);overflow:auto;padding:.7720588235em 1.1764705882em;scrollbar-color:var(--md-default-fg-color--lighter) #0000;scrollbar-width:thin;touch-action:auto;word-break:normal}.md-typeset pre>code:hover{scrollbar-color:var(--md-accent-fg-color) #0000}.md-typeset pre>code::-webkit-scrollbar{height:.2rem;width:.2rem}.md-typeset pre>code::-webkit-scrollbar-thumb{background-color:var(--md-default-fg-color--lighter)}.md-typeset pre>code::-webkit-scrollbar-thumb:hover{background-color:var(--md-accent-fg-color)}.md-typeset kbd{background-color:var(--md-typeset-kbd-color);border-radius:.1rem;box-shadow:0 .1rem 0 .05rem var(--md-typeset-kbd-border-color),0 .1rem 0 var(--md-typeset-kbd-border-color),0 -.1rem .2rem var(--md-typeset-kbd-accent-color) inset;color:var(--md-default-fg-color);display:inline-block;font-size:.75em;padding:0 .6666666667em;vertical-align:text-top;word-break:break-word}.md-typeset mark{background-color:var(--md-typeset-mark-color);-webkit-box-decoration-break:clone;box-decoration-break:clone;color:inherit;word-break:break-word}.md-typeset abbr{cursor:help;text-decoration:none}.md-typeset [data-preview],.md-typeset abbr{border-bottom:.05rem dotted var(--md-default-fg-color--light)}.md-typeset small{opacity:.75}[dir=ltr] .md-typeset sub,[dir=ltr] .md-typeset sup{margin-left:.078125em}[dir=rtl] .md-typeset sub,[dir=rtl] .md-typeset sup{margin-right:.078125em}[dir=ltr] .md-typeset blockquote{padding-left:.6rem}[dir=rtl] .md-typeset blockquote{padding-right:.6rem}[dir=ltr] .md-typeset blockquote{border-left:.2rem solid var(--md-default-fg-color--lighter)}[dir=rtl] .md-typeset blockquote{border-right:.2rem solid var(--md-default-fg-color--lighter)}.md-typeset blockquote{color:var(--md-default-fg-color--light);margin-left:0;margin-right:0}.md-typeset ul{list-style-type:disc}.md-typeset ul[type]{list-style-type:revert-layer}[dir=ltr] .md-typeset ol,[dir=ltr] .md-typeset ul{margin-left:.625em}[dir=rtl] .md-typeset ol,[dir=rtl] .md-typeset ul{margin-right:.625em}.md-typeset ol,.md-typeset ul{padding:0}.md-typeset ol:not([hidden]),.md-typeset ul:not([hidden]){display:flow-root}.md-typeset ol ol,.md-typeset ul ol{list-style-type:lower-alpha}.md-typeset ol ol ol,.md-typeset ul ol ol{list-style-type:lower-roman}.md-typeset ol ol ol ol,.md-typeset ul ol ol ol{list-style-type:upper-alpha}.md-typeset ol ol ol ol ol,.md-typeset ul ol ol ol ol{list-style-type:upper-roman}.md-typeset ol[type],.md-typeset ul[type]{list-style-type:revert-layer}[dir=ltr] .md-typeset ol li,[dir=ltr] .md-typeset ul li{margin-left:1.25em}[dir=rtl] .md-typeset ol li,[dir=rtl] .md-typeset ul li{margin-right:1.25em}.md-typeset ol li,.md-typeset ul li{margin-bottom:.5em}.md-typeset ol li blockquote,.md-typeset ol li p,.md-typeset ul li blockquote,.md-typeset ul li p{margin:.5em 0}.md-typeset ol li:last-child,.md-typeset ul li:last-child{margin-bottom:0}[dir=ltr] .md-typeset ol li ol,[dir=ltr] .md-typeset ol li ul,[dir=ltr] .md-typeset ul li ol,[dir=ltr] .md-typeset ul li ul{margin-left:.625em}[dir=rtl] .md-typeset ol li ol,[dir=rtl] .md-typeset ol li ul,[dir=rtl] .md-typeset ul li ol,[dir=rtl] .md-typeset ul li ul{margin-right:.625em}.md-typeset ol li ol,.md-typeset ol li ul,.md-typeset ul li ol,.md-typeset ul li ul{margin-bottom:.5em;margin-top:.5em}[dir=ltr] .md-typeset dd{margin-left:1.875em}[dir=rtl] .md-typeset dd{margin-right:1.875em}.md-typeset dd{margin-bottom:1.5em;margin-top:1em}.md-typeset img,.md-typeset svg,.md-typeset video{height:auto;max-width:100%}.md-typeset img[align=left]{margin:1em 1em 1em 0}.md-typeset img[align=right]{margin:1em 0 1em 1em}.md-typeset img[align]:only-child{margin-top:0}.md-typeset figure{display:flow-root;margin:1em auto;max-width:100%;text-align:center;width:fit-content}.md-typeset figure img{display:block;margin:0 auto}.md-typeset figcaption{font-style:italic;margin:1em auto;max-width:24rem}.md-typeset iframe{max-width:100%}.md-typeset table:not([class]){background-color:var(--md-default-bg-color);border:.05rem solid var(--md-typeset-table-color);border-radius:.1rem;display:inline-block;font-size:.64rem;max-width:100%;overflow:auto;touch-action:auto}@media print{.md-typeset table:not([class]){display:table}}.md-typeset table:not([class])+*{margin-top:1.5em}.md-typeset table:not([class]) td>:first-child,.md-typeset table:not([class]) th>:first-child{margin-top:0}.md-typeset table:not([class]) td>:last-child,.md-typeset table:not([class]) th>:last-child{margin-bottom:0}.md-typeset table:not([class]) td:not([align]),.md-typeset table:not([class]) th:not([align]){text-align:left}[dir=rtl] .md-typeset table:not([class]) td:not([align]),[dir=rtl] .md-typeset table:not([class]) th:not([align]){text-align:right}.md-typeset table:not([class]) th{font-weight:700;min-width:5rem;padding:.9375em 1.25em;vertical-align:top}.md-typeset table:not([class]) td{border-top:.05rem solid var(--md-typeset-table-color);padding:.9375em 1.25em;vertical-align:top}.md-typeset table:not([class]) tbody tr{transition:background-color 125ms}.md-typeset table:not([class]) tbody tr:hover{background-color:var(--md-typeset-table-color--light);box-shadow:0 .05rem 0 var(--md-default-bg-color) inset}.md-typeset table:not([class]) a{word-break:normal}.md-typeset table th[role=columnheader]{cursor:pointer}[dir=ltr] .md-typeset table th[role=columnheader]:after{margin-left:.5em}[dir=rtl] .md-typeset table th[role=columnheader]:after{margin-right:.5em}.md-typeset table th[role=columnheader]:after{content:"";display:inline-block;height:1.2em;-webkit-mask-image:var(--md-typeset-table-sort-icon);mask-image:var(--md-typeset-table-sort-icon);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:background-color 125ms;vertical-align:text-bottom;width:1.2em}.md-typeset table th[role=columnheader]:hover:after{background-color:var(--md-default-fg-color--lighter)}.md-typeset table th[role=columnheader][aria-sort=ascending]:after{background-color:var(--md-default-fg-color--light);-webkit-mask-image:var(--md-typeset-table-sort-icon--asc);mask-image:var(--md-typeset-table-sort-icon--asc)}.md-typeset table th[role=columnheader][aria-sort=descending]:after{background-color:var(--md-default-fg-color--light);-webkit-mask-image:var(--md-typeset-table-sort-icon--desc);mask-image:var(--md-typeset-table-sort-icon--desc)}.md-typeset__scrollwrap{margin:1em -.8rem;overflow-x:auto;touch-action:auto}.md-typeset__table{display:inline-block;margin-bottom:.5em;padding:0 .8rem}@media print{.md-typeset__table{display:block}}html .md-typeset__table table{display:table;margin:0;overflow:hidden;width:100%}@media screen and (max-width:44.984375em){.md-content__inner>pre{margin:1em -.8rem}.md-content__inner>pre code{border-radius:0}}.md-typeset .md-author{border-radius:100%;display:block;flex-shrink:0;height:1.6rem;overflow:hidden;position:relative;transition:color 125ms,transform 125ms;width:1.6rem}.md-typeset .md-author img{display:block}.md-typeset .md-author--more{background:var(--md-default-fg-color--lightest);color:var(--md-default-fg-color--lighter);font-size:.6rem;font-weight:700;line-height:1.6rem;text-align:center}.md-typeset .md-author--long{height:2.4rem;width:2.4rem}.md-typeset a.md-author{transform:scale(1)}.md-typeset a.md-author img{border-radius:100%;filter:grayscale(100%) opacity(75%);transition:filter 125ms}.md-typeset a.md-author:focus,.md-typeset a.md-author:hover{transform:scale(1.1);z-index:1}.md-typeset a.md-author:focus img,.md-typeset a.md-author:hover img{filter:grayscale(0)}.md-banner{background-color:var(--md-footer-bg-color);color:var(--md-footer-fg-color);overflow:auto}@media print{.md-banner{display:none}}.md-banner--warning{background-color:var(--md-warning-bg-color);color:var(--md-warning-fg-color)}.md-banner__inner{font-size:.7rem;margin:.6rem auto;padding:0 .8rem}[dir=ltr] .md-banner__button{float:right}[dir=rtl] .md-banner__button{float:left}.md-banner__button{color:inherit;cursor:pointer;transition:opacity .25s}.no-js .md-banner__button{display:none}.md-banner__button:hover{opacity:.7}html{scrollbar-gutter:stable;font-size:125%;height:100%;overflow-x:hidden}@media screen and (min-width:100em){html{font-size:137.5%}}@media screen and (min-width:125em){html{font-size:150%}}body{background-color:var(--md-default-bg-color);display:flex;flex-direction:column;font-size:.5rem;min-height:100%;position:relative;width:100%}@media print{body{display:block}}@media screen and (max-width:59.984375em){body[data-md-scrolllock]{position:fixed}}.md-grid{margin-left:auto;margin-right:auto;max-width:61rem}.md-container{display:flex;flex-direction:column;flex-grow:1}@media print{.md-container{display:block}}.md-main{flex-grow:1}.md-main__inner{display:flex;height:100%;margin-top:1.5rem}.md-ellipsis{overflow:hidden;text-overflow:ellipsis}.md-toggle{display:none}.md-option{height:0;opacity:0;position:absolute;width:0}.md-option:checked+label:not([hidden]){display:block}.md-option.focus-visible+label{outline-color:var(--md-accent-fg-color);outline-style:auto}.md-skip{background-color:var(--md-default-fg-color);border-radius:.1rem;color:var(--md-default-bg-color);font-size:.64rem;margin:.5rem;opacity:0;outline-color:var(--md-accent-fg-color);padding:.3rem .5rem;position:fixed;transform:translateY(.4rem);z-index:-1}.md-skip:focus{opacity:1;transform:translateY(0);transition:transform .25s cubic-bezier(.4,0,.2,1),opacity 175ms 75ms;z-index:10}@page{margin:25mm}:root{--md-clipboard-icon:url('data:image/svg+xml;charset=utf-8,')}.md-clipboard{border-radius:.1rem;color:var(--md-default-fg-color--lightest);cursor:pointer;height:1.5em;outline-color:var(--md-accent-fg-color);outline-offset:.1rem;transition:color .25s;width:1.5em;z-index:1}@media print{.md-clipboard{display:none}}.md-clipboard:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}:hover>.md-clipboard{color:var(--md-default-fg-color--light)}.md-clipboard:focus,.md-clipboard:hover{color:var(--md-accent-fg-color)}.md-clipboard:after{background-color:currentcolor;content:"";display:block;height:1.125em;margin:0 auto;-webkit-mask-image:var(--md-clipboard-icon);mask-image:var(--md-clipboard-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:1.125em}.md-clipboard--inline{cursor:pointer}.md-clipboard--inline code{transition:color .25s,background-color .25s}.md-clipboard--inline:focus code,.md-clipboard--inline:hover code{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}:root{--md-code-select-icon:url('data:image/svg+xml;charset=utf-8,');--md-code-copy-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .md-code__content{display:grid}.md-code__nav{background-color:var(--md-code-bg-color--lighter);border-radius:.1rem;display:flex;gap:.2rem;padding:.2rem;position:absolute;right:.25em;top:.25em;transition:background-color .25s;z-index:1}:hover>.md-code__nav{background-color:var(--md-code-bg-color--light)}.md-code__button{color:var(--md-default-fg-color--lightest);cursor:pointer;display:block;height:1.5em;outline-color:var(--md-accent-fg-color);outline-offset:.1rem;transition:color .25s;width:1.5em}:hover>*>.md-code__button{color:var(--md-default-fg-color--light)}.md-code__button.focus-visible,.md-code__button:hover{color:var(--md-accent-fg-color)}.md-code__button--active{color:var(--md-default-fg-color)!important}.md-code__button:after{background-color:currentcolor;content:"";display:block;height:1.125em;margin:0 auto;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:1.125em}.md-code__button[data-md-type=select]:after{-webkit-mask-image:var(--md-code-select-icon);mask-image:var(--md-code-select-icon)}.md-code__button[data-md-type=copy]:after{-webkit-mask-image:var(--md-code-copy-icon);mask-image:var(--md-code-copy-icon)}@keyframes consent{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:translateY(0)}}@keyframes overlay{0%{opacity:0}to{opacity:1}}.md-consent__overlay{animation:overlay .25s both;-webkit-backdrop-filter:blur(.1rem);backdrop-filter:blur(.1rem);background-color:#0000008a;height:100%;opacity:1;position:fixed;top:0;width:100%;z-index:5}.md-consent__inner{animation:consent .5s cubic-bezier(.1,.7,.1,1) both;background-color:var(--md-default-bg-color);border:0;border-radius:.1rem;bottom:0;box-shadow:0 0 .2rem #0000001a,0 .2rem .4rem #0003;max-height:100%;overflow:auto;padding:0;position:fixed;width:100%;z-index:5}.md-consent__form{padding:.8rem}.md-consent__settings{display:none;margin:1em 0}input:checked+.md-consent__settings{display:block}.md-consent__controls{margin-bottom:.8rem}.md-typeset .md-consent__controls .md-button{display:inline}@media screen and (max-width:44.984375em){.md-typeset .md-consent__controls .md-button{display:block;margin-top:.4rem;text-align:center;width:100%}}.md-consent label{cursor:pointer}.md-content{flex-grow:1;min-width:0}.md-content__inner{margin:0 .8rem 1.2rem;padding-top:.6rem}@media screen and (min-width:76.25em){[dir=ltr] .md-sidebar--primary:not([hidden])~.md-content>.md-content__inner{margin-left:1.2rem}[dir=ltr] .md-sidebar--secondary:not([hidden])~.md-content>.md-content__inner,[dir=rtl] .md-sidebar--primary:not([hidden])~.md-content>.md-content__inner{margin-right:1.2rem}[dir=rtl] .md-sidebar--secondary:not([hidden])~.md-content>.md-content__inner{margin-left:1.2rem}}.md-content__inner:before{content:"";display:block;height:.4rem}.md-content__inner>:last-child{margin-bottom:0}[dir=ltr] .md-content__button{float:right}[dir=rtl] .md-content__button{float:left}[dir=ltr] .md-content__button{margin-left:.4rem}[dir=rtl] .md-content__button{margin-right:.4rem}.md-content__button{margin:.4rem 0;padding:0}@media print{.md-content__button{display:none}}.md-typeset .md-content__button{color:var(--md-default-fg-color--lighter)}.md-content__button svg{display:inline;vertical-align:top}[dir=rtl] .md-content__button svg{transform:scaleX(-1)}.md-content__button svg.lucide{fill:#0000;stroke:currentcolor}[dir=ltr] .md-dialog{right:.8rem}[dir=rtl] .md-dialog{left:.8rem}.md-dialog{background-color:var(--md-default-fg-color);border-radius:.1rem;bottom:.8rem;box-shadow:var(--md-shadow-z3);min-width:11.1rem;opacity:0;padding:.4rem .6rem;pointer-events:none;position:fixed;transform:translateY(100%);transition:transform 0ms .4s,opacity .4s;z-index:4}@media print{.md-dialog{display:none}}.md-dialog--active{opacity:1;pointer-events:auto;transform:translateY(0);transition:transform .4s cubic-bezier(.075,.85,.175,1),opacity .4s}.md-dialog__inner{color:var(--md-default-bg-color);font-size:.7rem}.md-feedback{margin:2em 0 1em;text-align:center}.md-feedback fieldset{border:none;margin:0;padding:0}.md-feedback__title{font-weight:700;margin:1em auto}.md-feedback__inner{position:relative}.md-feedback__list{display:flex;flex-wrap:wrap;place-content:baseline center;position:relative}.md-feedback__list:hover .md-icon:not(:disabled){color:var(--md-default-fg-color--lighter)}:disabled .md-feedback__list{min-height:1.8rem}.md-feedback__icon{color:var(--md-default-fg-color--light);cursor:pointer;flex-shrink:0;margin:0 .1rem;transition:color 125ms}.md-feedback__icon:not(:disabled).md-icon:hover{color:var(--md-accent-fg-color)}.md-feedback__icon:disabled{color:var(--md-default-fg-color--lightest);pointer-events:none}.md-feedback__note{opacity:0;position:relative;transform:translateY(.4rem);transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .15s}.md-feedback__note>*{margin:0 auto;max-width:16rem}:disabled .md-feedback__note{opacity:1;transform:translateY(0)}@media print{.md-feedback{display:none}}.md-footer{background-color:var(--md-footer-bg-color);color:var(--md-footer-fg-color)}@media print{.md-footer{display:none}}.md-footer__inner{justify-content:space-between;overflow:auto;padding:.2rem}.md-footer__inner:not([hidden]){display:flex}.md-footer__link{align-items:end;display:flex;flex-grow:0.01;margin-bottom:.4rem;margin-top:1rem;max-width:100%;outline-color:var(--md-accent-fg-color);overflow:hidden;transition:opacity .25s}.md-footer__link:focus,.md-footer__link:hover{opacity:.7}[dir=rtl] .md-footer__link svg{transform:scaleX(-1)}@media screen and (max-width:44.984375em){.md-footer__link--prev{flex-shrink:0}.md-footer__link--prev .md-footer__title{display:none}}[dir=ltr] .md-footer__link--next{margin-left:auto}[dir=rtl] .md-footer__link--next{margin-right:auto}.md-footer__link--next{text-align:right}[dir=rtl] .md-footer__link--next{text-align:left}.md-footer__title{flex-grow:1;font-size:.9rem;margin-bottom:.7rem;max-width:calc(100% - 2.4rem);padding:0 1rem;white-space:nowrap}.md-footer__button{margin:.2rem;padding:.4rem}.md-footer__direction{font-size:.64rem;opacity:.7}.md-footer-meta{background-color:var(--md-footer-bg-color--dark)}.md-footer-meta__inner{display:flex;flex-wrap:wrap;justify-content:space-between;padding:.2rem}html .md-footer-meta.md-typeset a{color:var(--md-footer-fg-color--light)}html .md-footer-meta.md-typeset a:focus,html .md-footer-meta.md-typeset a:hover{color:var(--md-footer-fg-color)}.md-copyright{color:var(--md-footer-fg-color--lighter);font-size:.64rem;margin:auto .6rem;padding:.4rem 0;width:100%}@media screen and (min-width:45em){.md-copyright{width:auto}}.md-copyright__highlight{color:var(--md-footer-fg-color--light)}.md-social{display:inline-flex;gap:.2rem;margin:0 .4rem;padding:.2rem 0 .6rem}@media screen and (min-width:45em){.md-social{padding:.6rem 0}}.md-social__link{display:inline-block;height:1.6rem;text-align:center;width:1.6rem}.md-social__link:before{line-height:1.9}.md-social__link svg{fill:currentcolor;max-height:.8rem;vertical-align:-25%}.md-social__link svg.lucide{fill:#0000;stroke:currentcolor}.md-typeset .md-button{border:.1rem solid;border-radius:.1rem;color:var(--md-primary-fg-color);cursor:pointer;display:inline-block;font-weight:700;padding:.625em 2em;transition:color 125ms,background-color 125ms,border-color 125ms}.md-typeset .md-button--primary{background-color:var(--md-primary-fg-color);border-color:var(--md-primary-fg-color);color:var(--md-primary-bg-color)}.md-typeset .md-button:focus,.md-typeset .md-button:hover{background-color:var(--md-accent-fg-color);border-color:var(--md-accent-fg-color);color:var(--md-accent-bg-color)}[dir=ltr] .md-typeset .md-input{border-top-left-radius:.1rem}[dir=ltr] .md-typeset .md-input,[dir=rtl] .md-typeset .md-input{border-top-right-radius:.1rem}[dir=rtl] .md-typeset .md-input{border-top-left-radius:.1rem}.md-typeset .md-input{border-bottom:.1rem solid var(--md-default-fg-color--lighter);box-shadow:var(--md-shadow-z1);font-size:.8rem;height:1.8rem;padding:0 .6rem;transition:border .25s,box-shadow .25s}.md-typeset .md-input:focus,.md-typeset .md-input:hover{border-bottom-color:var(--md-accent-fg-color);box-shadow:var(--md-shadow-z2)}.md-typeset .md-input--stretch{width:100%}.md-header{background-color:var(--md-primary-fg-color);box-shadow:0 0 .2rem #0000,0 .2rem .4rem #0000;color:var(--md-primary-bg-color);display:block;left:0;position:sticky;right:0;top:0;z-index:4}@media print{.md-header{display:none}}.md-header[hidden]{transform:translateY(-100%);transition:transform .25s cubic-bezier(.8,0,.6,1),box-shadow .25s}.md-header--shadow{box-shadow:0 0 .2rem #0000001a,0 .2rem .4rem #0003;transition:transform .25s cubic-bezier(.1,.7,.1,1),box-shadow .25s}.md-header__inner{align-items:center;display:flex;padding:0 .2rem}.md-header__button{color:currentcolor;cursor:pointer;margin:.2rem;outline-color:var(--md-accent-fg-color);padding:.4rem;position:relative;transition:opacity .25s;vertical-align:middle;z-index:1}.md-header__button:hover{opacity:.7}.md-header__button:not([hidden]){display:inline-block}.md-header__button:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}.md-header__button.md-logo{margin:.2rem;padding:.4rem}@media screen and (max-width:76.234375em){.md-header__button.md-logo{display:none}}.md-header__button.md-logo img,.md-header__button.md-logo svg{fill:currentcolor;display:block;height:1.2rem;width:auto}@media screen and (min-width:60em){.md-header__button[for=__search]{display:none}}.no-js .md-header__button[for=__search]{display:none}[dir=rtl] .md-header__button[for=__search] svg{transform:scaleX(-1)}@media screen and (min-width:76.25em){.md-header__button[for=__drawer]{display:none}}.md-header__topic{display:flex;max-width:100%;position:absolute;transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .15s;white-space:nowrap}.md-header__topic+.md-header__topic{opacity:0;pointer-events:none;transform:translateX(1.25rem);transition:transform .4s cubic-bezier(1,.7,.1,.1),opacity .15s;z-index:-1}[dir=rtl] .md-header__topic+.md-header__topic{transform:translateX(-1.25rem)}.md-header__topic:first-child{font-weight:700}[dir=ltr] .md-header__title{margin-left:1rem;margin-right:.4rem}[dir=rtl] .md-header__title{margin-left:.4rem;margin-right:1rem}.md-header__title{flex-grow:1;font-size:.9rem;height:2.4rem;line-height:2.4rem}.md-header__title--active .md-header__topic{opacity:0;pointer-events:none;transform:translateX(-1.25rem);transition:transform .4s cubic-bezier(1,.7,.1,.1),opacity .15s;z-index:-1}[dir=rtl] .md-header__title--active .md-header__topic{transform:translateX(1.25rem)}.md-header__title--active .md-header__topic+.md-header__topic{opacity:1;pointer-events:auto;transform:translateX(0);transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .15s;z-index:0}.md-header__title>.md-header__ellipsis{height:100%;position:relative;width:100%}.md-header__option{display:flex;flex-shrink:0;max-width:100%;white-space:nowrap}.md-header__option>input{bottom:0}.md-header__source{display:none}@media screen and (min-width:60em){[dir=ltr] .md-header__source{margin-left:1rem}[dir=rtl] .md-header__source{margin-right:1rem}.md-header__source{display:block;max-width:11.7rem;width:11.7rem}}@media screen and (min-width:76.25em){[dir=ltr] .md-header__source{margin-left:1.4rem}[dir=rtl] .md-header__source{margin-right:1.4rem}}.md-meta{color:var(--md-default-fg-color--light);font-size:.7rem;line-height:1.3}.md-meta__list{display:inline-flex;flex-wrap:wrap;list-style:none;margin:0;padding:0}.md-meta__item:not(:last-child):after{content:"·";margin-left:.2rem;margin-right:.2rem}.md-meta__link{color:var(--md-typeset-a-color)}.md-meta__link:focus,.md-meta__link:hover{color:var(--md-accent-fg-color)}.md-draft{background-color:#ff1744;border-radius:.125em;color:#fff;display:inline-block;font-weight:700;padding-left:.5714285714em;padding-right:.5714285714em}:root{--md-nav-icon--prev:url('data:image/svg+xml;charset=utf-8,');--md-nav-icon--next:url('data:image/svg+xml;charset=utf-8,');--md-toc-icon:url('data:image/svg+xml;charset=utf-8,')}.md-nav{font-size:.7rem;line-height:1.3}.md-nav__title{color:var(--md-default-fg-color--light);display:block;font-weight:700;overflow:hidden;padding:0 .6rem;text-overflow:ellipsis}.md-nav__title .md-nav__button{display:none}.md-nav__title .md-nav__button img{height:100%;width:auto}.md-nav__title .md-nav__button.md-logo img,.md-nav__title .md-nav__button.md-logo svg{fill:currentcolor;display:block;height:2.4rem;max-width:100%;object-fit:contain;width:auto}.md-nav__list{list-style:none;margin:0;padding:0}.md-nav__link{align-items:flex-start;display:flex;gap:.4rem;margin-top:.625em;scroll-snap-align:start;transition:color 125ms}.md-nav__link--passed,.md-nav__link--passed code{color:var(--md-default-fg-color--light)}.md-nav__item .md-nav__link--active,.md-nav__item .md-nav__link--active code{color:var(--md-typeset-a-color)}.md-nav__link .md-ellipsis{position:relative}.md-nav__link .md-ellipsis code{word-break:normal}[dir=ltr] .md-nav__link .md-icon:last-child{margin-left:auto}[dir=rtl] .md-nav__link .md-icon:last-child{margin-right:auto}.md-nav__link .md-typeset{font-size:.7rem;line-height:1.3}.md-nav__link svg{fill:currentcolor;flex-shrink:0;height:1.3em;position:relative;width:1.3em}.md-nav__link svg.lucide{fill:#0000;stroke:currentcolor}.md-nav__link[for]:focus,.md-nav__link[for]:hover,.md-nav__link[href]:focus,.md-nav__link[href]:hover{color:var(--md-accent-fg-color);cursor:pointer}.md-nav__link[for]:focus code,.md-nav__link[for]:hover code,.md-nav__link[href]:focus code,.md-nav__link[href]:hover code{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}.md-nav__link.focus-visible{outline-color:var(--md-accent-fg-color);outline-offset:.2rem}.md-nav--primary .md-nav__link[for=__toc]{display:none}.md-nav--primary .md-nav__link[for=__toc] .md-icon:after{background-color:currentcolor;display:block;height:100%;-webkit-mask-image:var(--md-toc-icon);mask-image:var(--md-toc-icon);width:100%}.md-nav--primary .md-nav__link[for=__toc]~.md-nav{display:none}.md-nav__container>.md-nav__link{margin-top:0}.md-nav__container>.md-nav__link:first-child{flex-grow:1;min-width:0}.md-nav__icon{flex-shrink:0}.md-nav__source{display:none}@media screen and (max-width:76.234375em){.md-nav--primary,.md-nav--primary .md-nav{background-color:var(--md-default-bg-color);display:flex;flex-direction:column;height:100%;left:0;position:absolute;right:0;top:0;z-index:1}.md-nav--primary .md-nav__item,.md-nav--primary .md-nav__title{font-size:.8rem;line-height:1.5}.md-nav--primary .md-nav__title{background-color:var(--md-default-fg-color--lightest);color:var(--md-default-fg-color--light);cursor:pointer;height:5.6rem;line-height:2.4rem;padding:3rem .8rem .2rem;position:relative;white-space:nowrap}[dir=ltr] .md-nav--primary .md-nav__title .md-nav__icon{left:.4rem}[dir=rtl] .md-nav--primary .md-nav__title .md-nav__icon{right:.4rem}.md-nav--primary .md-nav__title .md-nav__icon{display:block;height:1.2rem;margin:.2rem;position:absolute;top:.4rem;width:1.2rem}.md-nav--primary .md-nav__title .md-nav__icon:after{background-color:currentcolor;content:"";display:block;height:100%;-webkit-mask-image:var(--md-nav-icon--prev);mask-image:var(--md-nav-icon--prev);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:100%}.md-nav--primary .md-nav__title~.md-nav__list{background-color:var(--md-default-bg-color);box-shadow:0 .05rem 0 var(--md-default-fg-color--lightest) inset;overflow-y:auto;overscroll-behavior-y:contain;scroll-snap-type:y mandatory;touch-action:pan-y}.md-nav--primary .md-nav__title~.md-nav__list>:first-child{border-top:0}.md-nav--primary .md-nav__title[for=__drawer]{background-color:var(--md-primary-fg-color);color:var(--md-primary-bg-color);font-weight:700}.md-nav--primary .md-nav__title .md-logo{display:block;left:.2rem;margin:.2rem;padding:.4rem;position:absolute;right:.2rem;top:.2rem}.md-nav--primary .md-nav__list{flex:1}.md-nav--primary .md-nav__item{border-top:.05rem solid var(--md-default-fg-color--lightest)}.md-nav--primary .md-nav__item--active>.md-nav__link{color:var(--md-typeset-a-color)}.md-nav--primary .md-nav__item--active>.md-nav__link:focus,.md-nav--primary .md-nav__item--active>.md-nav__link:hover{color:var(--md-accent-fg-color)}.md-nav--primary .md-nav__link{margin-top:0;padding:.6rem .8rem}.md-nav--primary .md-nav__link svg{margin-top:.1em}.md-nav--primary .md-nav__link>.md-nav__link{padding:0}[dir=ltr] .md-nav--primary .md-nav__link .md-nav__icon{margin-right:-.2rem}[dir=rtl] .md-nav--primary .md-nav__link .md-nav__icon{margin-left:-.2rem}.md-nav--primary .md-nav__link .md-nav__icon{font-size:1.2rem;height:1.2rem;width:1.2rem}.md-nav--primary .md-nav__link .md-nav__icon:after{background-color:currentcolor;content:"";display:block;height:100%;-webkit-mask-image:var(--md-nav-icon--next);mask-image:var(--md-nav-icon--next);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:100%}[dir=rtl] .md-nav--primary .md-nav__icon:after{transform:scale(-1)}.md-nav--primary .md-nav--secondary .md-nav{background-color:initial;position:static}[dir=ltr] .md-nav--primary .md-nav--secondary .md-nav .md-nav__link{padding-left:1.4rem}[dir=rtl] .md-nav--primary .md-nav--secondary .md-nav .md-nav__link{padding-right:1.4rem}[dir=ltr] .md-nav--primary .md-nav--secondary .md-nav .md-nav .md-nav__link{padding-left:2rem}[dir=rtl] .md-nav--primary .md-nav--secondary .md-nav .md-nav .md-nav__link{padding-right:2rem}[dir=ltr] .md-nav--primary .md-nav--secondary .md-nav .md-nav .md-nav .md-nav__link{padding-left:2.6rem}[dir=rtl] .md-nav--primary .md-nav--secondary .md-nav .md-nav .md-nav .md-nav__link{padding-right:2.6rem}[dir=ltr] .md-nav--primary .md-nav--secondary .md-nav .md-nav .md-nav .md-nav .md-nav__link{padding-left:3.2rem}[dir=rtl] .md-nav--primary .md-nav--secondary .md-nav .md-nav .md-nav .md-nav .md-nav__link{padding-right:3.2rem}.md-nav--secondary{background-color:initial}.md-nav__toggle~.md-nav{display:flex;opacity:0;transform:translateX(100%);transition:transform .25s cubic-bezier(.8,0,.6,1),opacity 125ms 50ms}[dir=rtl] .md-nav__toggle~.md-nav{transform:translateX(-100%)}.md-nav__toggle:checked~.md-nav{opacity:1;transform:translateX(0);transition:transform .25s cubic-bezier(.4,0,.2,1),opacity 125ms 125ms}.md-nav__toggle:checked~.md-nav>.md-nav__list{backface-visibility:hidden}}@media screen and (max-width:59.984375em){.md-nav--primary .md-nav__link[for=__toc]{display:flex}.md-nav--primary .md-nav__link[for=__toc] .md-icon:after{content:""}.md-nav--primary .md-nav__link[for=__toc]+.md-nav__link{display:none}.md-nav--primary .md-nav__link[for=__toc]~.md-nav{display:flex}.md-nav__source{background-color:var(--md-primary-fg-color--dark);color:var(--md-primary-bg-color);display:block;padding:0 .2rem}}@media screen and (min-width:60em) and (max-width:76.234375em){.md-nav--integrated .md-nav__link[for=__toc]{display:flex}.md-nav--integrated .md-nav__link[for=__toc] .md-icon:after{content:""}.md-nav--integrated .md-nav__link[for=__toc]+.md-nav__link{display:none}.md-nav--integrated .md-nav__link[for=__toc]~.md-nav{display:flex}}@media screen and (min-width:60em){.md-nav{margin-bottom:-.4rem}.md-nav--secondary .md-nav__title{background:var(--md-default-bg-color);box-shadow:0 0 .4rem .4rem var(--md-default-bg-color);position:sticky;top:0;z-index:1}.md-nav--secondary .md-nav__title[for=__toc]{scroll-snap-align:start}.md-nav--secondary .md-nav__title .md-nav__icon{display:none}[dir=ltr] .md-nav--secondary .md-nav__list{padding-left:.6rem}[dir=rtl] .md-nav--secondary .md-nav__list{padding-right:.6rem}.md-nav--secondary .md-nav__list{padding-bottom:.4rem}[dir=ltr] .md-nav--secondary .md-nav__item>.md-nav__link{margin-right:.4rem}[dir=rtl] .md-nav--secondary .md-nav__item>.md-nav__link{margin-left:.4rem}}@media screen and (min-width:76.25em){.md-nav{margin-bottom:-.4rem;transition:max-height .25s cubic-bezier(.86,0,.07,1)}.md-nav--primary .md-nav__title{background:var(--md-default-bg-color);box-shadow:0 0 .4rem .4rem var(--md-default-bg-color);position:sticky;top:0;z-index:1}.md-nav--primary .md-nav__title[for=__drawer]{scroll-snap-align:start}.md-nav--primary .md-nav__title .md-nav__icon{display:none}[dir=ltr] .md-nav--primary .md-nav__list{padding-left:.6rem}[dir=rtl] .md-nav--primary .md-nav__list{padding-right:.6rem}.md-nav--primary .md-nav__list{padding-bottom:.4rem}[dir=ltr] .md-nav--primary .md-nav__item>.md-nav__link{margin-right:.4rem}[dir=rtl] .md-nav--primary .md-nav__item>.md-nav__link{margin-left:.4rem}.md-nav__toggle~.md-nav{display:grid;grid-template-rows:minmax(.4rem,0fr);opacity:0;transition:grid-template-rows .25s cubic-bezier(.86,0,.07,1),opacity .25s,visibility 0ms .25s;visibility:collapse}.md-nav__toggle~.md-nav>.md-nav__list{overflow:hidden}.md-nav__toggle.md-toggle--indeterminate~.md-nav,.md-nav__toggle:checked~.md-nav{grid-template-rows:minmax(.4rem,1fr);opacity:1;transition:grid-template-rows .25s cubic-bezier(.86,0,.07,1),opacity .15s .1s,visibility 0ms;visibility:visible}.md-nav__toggle.md-toggle--indeterminate~.md-nav{transition:none}.md-nav__item--nested>.md-nav>.md-nav__title{display:none}.md-nav__item--section{display:block;margin:1.25em 0}.md-nav__item--section:last-child{margin-bottom:0}.md-nav__item--section>.md-nav__link{font-weight:700}.md-nav__item--section>.md-nav__link[for]{color:var(--md-default-fg-color--light)}.md-nav__item--section>.md-nav__link:not(.md-nav__container){pointer-events:none}.md-nav__item--section>.md-nav__link .md-icon,.md-nav__item--section>.md-nav__link>[for]{display:none}[dir=ltr] .md-nav__item--section>.md-nav{margin-left:-.6rem}[dir=rtl] .md-nav__item--section>.md-nav{margin-right:-.6rem}.md-nav__item--section>.md-nav{display:block;opacity:1;visibility:visible}.md-nav__item--section>.md-nav>.md-nav__list>.md-nav__item{padding:0}.md-nav__icon{border-radius:100%;height:.9rem;transition:background-color .25s;width:.9rem}.md-nav__icon:hover{background-color:var(--md-accent-fg-color--transparent)}.md-nav__icon:after{background-color:currentcolor;border-radius:100%;content:"";display:inline-block;height:100%;-webkit-mask-image:var(--md-nav-icon--next);mask-image:var(--md-nav-icon--next);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:transform .25s;vertical-align:-.1rem;width:100%}[dir=rtl] .md-nav__icon:after{transform:rotate(180deg)}.md-nav__item--nested .md-nav__toggle:checked~.md-nav__link .md-nav__icon:after,.md-nav__item--nested .md-toggle--indeterminate~.md-nav__link .md-nav__icon:after{transform:rotate(90deg)}.md-nav--lifted>.md-nav__list>.md-nav__item,.md-nav--lifted>.md-nav__title{display:none}.md-nav--lifted>.md-nav__list>.md-nav__item--active{display:block}.md-nav--lifted>.md-nav__list>.md-nav__item--active>.md-nav__link{background:var(--md-default-bg-color);box-shadow:0 0 .4rem .4rem var(--md-default-bg-color);margin-top:0;position:sticky;top:0;z-index:1}.md-nav--lifted>.md-nav__list>.md-nav__item--active>.md-nav__link:not(.md-nav__container){pointer-events:none}.md-nav--lifted>.md-nav__list>.md-nav__item--active.md-nav__item--section{margin:0}[dir=ltr] .md-nav--lifted>.md-nav__list>.md-nav__item>.md-nav:not(.md-nav--secondary){margin-left:-.6rem}[dir=rtl] .md-nav--lifted>.md-nav__list>.md-nav__item>.md-nav:not(.md-nav--secondary){margin-right:-.6rem}.md-nav--lifted>.md-nav__list>.md-nav__item>[for]{color:var(--md-default-fg-color--light)}.md-nav--lifted .md-nav[data-md-level="1"]{grid-template-rows:minmax(.4rem,1fr);opacity:1;visibility:visible}[dir=ltr] .md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{border-left:.05rem solid var(--md-primary-fg-color)}[dir=rtl] .md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{border-right:.05rem solid var(--md-primary-fg-color)}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{display:block;margin-bottom:1.25em;opacity:1;visibility:visible}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary>.md-nav__list{overflow:visible;padding-bottom:0}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary>.md-nav__title{display:none}}.md-pagination{font-size:.8rem;font-weight:700;gap:.4rem}.md-pagination,.md-pagination>*{align-items:center;display:flex;justify-content:center}.md-pagination>*{border-radius:.2rem;height:1.8rem;min-width:1.8rem;text-align:center}.md-pagination__current{background-color:var(--md-default-fg-color--lightest);color:var(--md-default-fg-color--light)}.md-pagination__link{transition:color 125ms,background-color 125ms}.md-pagination__link:focus,.md-pagination__link:hover{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}.md-pagination__link:focus svg,.md-pagination__link:hover svg{color:var(--md-accent-fg-color)}.md-pagination__link.focus-visible{outline-color:var(--md-accent-fg-color);outline-offset:.2rem}.md-pagination__link svg{fill:currentcolor;color:var(--md-default-fg-color--lighter);display:block;max-height:100%;width:1.2rem}:root{--md-path-icon:url('data:image/svg+xml;charset=utf-8,')}.md-path{font-size:.7rem;margin:0 .8rem;overflow:auto;padding-top:1.2rem}.md-path:not([hidden]){display:block}@media screen and (min-width:76.25em){.md-path{margin:0 1.2rem}}.md-path__list{align-items:center;display:flex;gap:.2rem;list-style:none;margin:0;padding:0}.md-path__item:not(:first-child){display:inline-flex;gap:.2rem;white-space:nowrap}.md-path__item:not(:first-child):before{background-color:var(--md-default-fg-color--lighter);content:"";display:inline;height:.8rem;-webkit-mask-image:var(--md-path-icon);mask-image:var(--md-path-icon);width:.8rem}.md-path__link{align-items:center;color:var(--md-default-fg-color--light);display:flex}.md-path__link:focus,.md-path__link:hover{color:var(--md-accent-fg-color)}:root{--md-post-pin-icon:url('data:image/svg+xml;charset=utf-8,')}.md-post__back{border-bottom:.05rem solid var(--md-default-fg-color--lightest);margin-bottom:1.2rem;padding-bottom:1.2rem}@media screen and (max-width:76.234375em){.md-post__back{display:none}}[dir=rtl] .md-post__back svg{transform:scaleX(-1)}.md-post__authors{display:flex;flex-direction:column;gap:.6rem;margin:0 .6rem 1.2rem}.md-post .md-post__meta a{transition:color 125ms}.md-post .md-post__meta a:focus,.md-post .md-post__meta a:hover{color:var(--md-accent-fg-color)}.md-post__title{color:var(--md-default-fg-color--light);font-weight:700}.md-post--excerpt{margin-bottom:3.2rem}.md-post--excerpt .md-post__header{align-items:center;display:flex;gap:.6rem;min-height:1.6rem}.md-post--excerpt .md-post__authors{align-items:center;display:inline-flex;flex-direction:row;gap:.2rem;margin:0;min-height:2.4rem}[dir=ltr] .md-post--excerpt .md-post__meta .md-meta__list{margin-right:.4rem}[dir=rtl] .md-post--excerpt .md-post__meta .md-meta__list{margin-left:.4rem}.md-post--excerpt .md-post__content>:first-child{--md-scroll-margin:6rem;margin-top:0}.md-post>.md-nav--secondary{margin:1em 0}.md-pin{background:var(--md-default-fg-color--lightest);border-radius:1rem;margin-top:-.05rem;padding:.2rem}.md-pin:after{background-color:currentcolor;content:"";display:block;height:.6rem;margin:0 auto;-webkit-mask-image:var(--md-post-pin-icon);mask-image:var(--md-post-pin-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:.6rem}.md-profile{align-items:center;display:flex;font-size:.7rem;gap:.6rem;line-height:1.4;width:100%}.md-profile__description{flex-grow:1}.md-content--post{display:flex}@media screen and (max-width:76.234375em){.md-content--post{flex-flow:column-reverse}}.md-content--post>.md-content__inner{flex-grow:1;min-width:0}@media screen and (min-width:76.25em){[dir=ltr] .md-content--post>.md-content__inner{margin-left:1.2rem}[dir=rtl] .md-content--post>.md-content__inner{margin-right:1.2rem}}@media screen and (max-width:76.234375em){.md-sidebar.md-sidebar--post{padding:0;position:static;width:100%}.md-sidebar.md-sidebar--post .md-sidebar__scrollwrap{overflow:visible}.md-sidebar.md-sidebar--post .md-sidebar__inner{padding:0}.md-sidebar.md-sidebar--post .md-post__meta{margin-left:.6rem;margin-right:.6rem}.md-sidebar.md-sidebar--post .md-nav__item{border:none;display:inline}.md-sidebar.md-sidebar--post .md-nav__list{display:inline-flex;flex-wrap:wrap;gap:.6rem;padding-bottom:.6rem;padding-top:.6rem}.md-sidebar.md-sidebar--post .md-nav__link{padding:0}.md-sidebar.md-sidebar--post .md-nav{height:auto;margin-bottom:0;position:static}}:root{--md-progress-value:0;--md-progress-delay:400ms}.md-progress{background:var(--md-primary-bg-color);height:.075rem;opacity:min(clamp(0,var(--md-progress-value),1),clamp(0,100 - var(--md-progress-value),1));position:fixed;top:0;transform:scaleX(calc(var(--md-progress-value)*1%));transform-origin:left;transition:transform .5s cubic-bezier(.19,1,.22,1),opacity .25s var(--md-progress-delay);width:100%;z-index:4}:root{--md-search-icon:url('data:image/svg+xml;charset=utf-8,')}.md-search{position:relative}@media screen and (min-width:60em){.md-search{padding:.2rem 0}}@media screen and (max-width:59.984375em){.md-search{display:none}}.no-js .md-search{display:none}[dir=ltr] .md-search__button{padding-left:1.9rem;padding-right:2.2rem}[dir=rtl] .md-search__button{padding-left:2.2rem;padding-right:1.9rem}.md-search__button{background:var(--md-primary-fg-color);color:var(--md-primary-bg-color);cursor:pointer;font-size:.7rem;position:relative;text-align:left}@media screen and (min-width:45em){.md-search__button{background-color:#00000042;border-radius:.2rem;height:1.6rem;transition:background-color .4s,color .4s;width:8.9rem}.md-search__button:focus,.md-search__button:hover{background-color:#ffffff1f;color:var(--md-primary-bg-color)}}[dir=ltr] .md-search__button:before{left:0}[dir=rtl] .md-search__button:before{right:0}.md-search__button:before{background-color:var(--md-primary-bg-color);content:"";height:1rem;margin-left:.5rem;-webkit-mask-image:var(--md-search-icon);mask-image:var(--md-search-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.3rem;width:1rem}.md-search__button:after{background:#00000042;border-radius:.1rem;content:"Ctrl+K";display:block;font-size:.6rem;padding:.1rem .2rem;position:absolute;right:.6rem;top:.35rem}[data-platform^=Mac] .md-search__button:after{content:"⌘K"}.md-select{position:relative;z-index:1}.md-select__inner{background-color:var(--md-default-bg-color);border-radius:.1rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color);left:50%;margin-top:.2rem;max-height:0;opacity:0;position:absolute;top:calc(100% - .2rem);transform:translate3d(-50%,.3rem,0);transition:transform .25s 375ms,opacity .25s .25s,max-height 0ms .5s}@media screen and (max-width:59.984375em){.md-select__inner{left:100%;transform:translate3d(-100%,.3rem,0)}}.md-select:focus-within .md-select__inner,.md-select:hover .md-select__inner{max-height:min(75vh,28rem);opacity:1;transform:translate3d(-50%,0,0);transition:transform .25s cubic-bezier(.1,.7,.1,1),opacity .25s,max-height 0ms}@media screen and (max-width:59.984375em){.md-select:focus-within .md-select__inner,.md-select:hover .md-select__inner{transform:translate3d(-100%,0,0)}}.md-select__inner:after{border-bottom:.2rem solid #0000;border-bottom-color:var(--md-default-bg-color);border-left:.2rem solid #0000;border-right:.2rem solid #0000;border-top:0;content:"";filter:drop-shadow(0 -1px 0 var(--md-default-fg-color--lightest));height:0;left:50%;margin-left:-.2rem;margin-top:-.2rem;position:absolute;top:0;width:0}@media screen and (max-width:59.984375em){.md-select__inner:after{left:auto;right:1rem}}.md-select__list{border-radius:.1rem;font-size:.8rem;list-style-type:none;margin:0;max-height:inherit;overflow:auto;padding:0}.md-select__item{line-height:1.8rem}[dir=ltr] .md-select__link{padding-left:.6rem;padding-right:1.2rem}[dir=rtl] .md-select__link{padding-left:1.2rem;padding-right:.6rem}.md-select__link{cursor:pointer;display:block;outline:none;scroll-snap-align:start;transition:background-color .25s,color .25s;width:100%}.md-select__link:focus,.md-select__link:hover{color:var(--md-accent-fg-color)}.md-select__link:focus{background-color:var(--md-default-fg-color--lightest)}.md-sidebar{align-self:flex-start;flex-shrink:0;padding:1.2rem 0;position:sticky;top:2.4rem;width:12.1rem}@media print{.md-sidebar{display:none}}@media screen and (max-width:76.234375em){[dir=ltr] .md-sidebar--primary{left:-12.1rem}[dir=rtl] .md-sidebar--primary{right:-12.1rem}.md-sidebar--primary{background-color:var(--md-default-bg-color);display:block;height:100%;position:fixed;top:0;transform:translateX(0);transition:transform .25s cubic-bezier(.4,0,.2,1),box-shadow .25s;width:12.1rem;z-index:5}[data-md-toggle=drawer]:checked~.md-container .md-sidebar--primary{box-shadow:var(--md-shadow-z3);transform:translateX(12.1rem)}[dir=rtl] [data-md-toggle=drawer]:checked~.md-container .md-sidebar--primary{transform:translateX(-12.1rem)}.md-sidebar--primary .md-sidebar__scrollwrap{bottom:0;left:0;margin:0;overflow:hidden;overscroll-behavior-y:contain;position:absolute;right:0;scroll-snap-type:none;top:0}}@media screen and (min-width:76.25em){.md-sidebar{height:0}.no-js .md-sidebar{height:auto}.md-header--lifted~.md-container .md-sidebar{top:4.8rem}}.md-sidebar--secondary{display:none;order:2}@media screen and (min-width:60em){.md-sidebar--secondary{height:0}.no-js .md-sidebar--secondary{height:auto}.md-sidebar--secondary:not([hidden]){display:block}.md-sidebar--secondary .md-sidebar__scrollwrap{touch-action:pan-y}}.md-sidebar__scrollwrap{backface-visibility:hidden;margin:0 .2rem;overflow-y:auto;scrollbar-color:var(--md-default-fg-color--lighter) #0000}@media screen and (min-width:60em){.md-sidebar__scrollwrap{scrollbar-gutter:stable;scrollbar-width:thin}}.md-sidebar__scrollwrap::-webkit-scrollbar{height:.2rem;width:.2rem}.md-sidebar__scrollwrap:focus-within,.md-sidebar__scrollwrap:hover{scrollbar-color:var(--md-accent-fg-color) #0000}.md-sidebar__scrollwrap:focus-within::-webkit-scrollbar-thumb,.md-sidebar__scrollwrap:hover::-webkit-scrollbar-thumb{background-color:var(--md-default-fg-color--lighter)}.md-sidebar__scrollwrap:focus-within::-webkit-scrollbar-thumb:hover,.md-sidebar__scrollwrap:hover::-webkit-scrollbar-thumb:hover{background-color:var(--md-accent-fg-color)}@supports selector(::-webkit-scrollbar){.md-sidebar__scrollwrap{scrollbar-gutter:auto}[dir=ltr] .md-sidebar__inner{padding-right:calc(100% - 11.5rem)}[dir=rtl] .md-sidebar__inner{padding-left:calc(100% - 11.5rem)}}@media screen and (max-width:76.234375em){.md-overlay{background-color:#0000008a;height:0;opacity:0;position:fixed;top:0;transition:width 0ms .25s,height 0ms .25s,opacity .25s;width:0;z-index:5}[data-md-toggle=drawer]:checked~.md-overlay{height:100%;opacity:1;transition:width 0ms,height 0ms,opacity .25s;width:100%}}@keyframes facts{0%{height:0}to{height:.65rem}}@keyframes fact{0%{opacity:0;transform:translateY(100%)}50%{opacity:0}to{opacity:1;transform:translateY(0)}}:root{--md-source-forks-icon:url('data:image/svg+xml;charset=utf-8,');--md-source-repositories-icon:url('data:image/svg+xml;charset=utf-8,');--md-source-stars-icon:url('data:image/svg+xml;charset=utf-8,');--md-source-version-icon:url('data:image/svg+xml;charset=utf-8,')}.md-source{backface-visibility:hidden;display:block;font-size:.65rem;line-height:1.2;outline-color:var(--md-accent-fg-color);transition:opacity .25s;white-space:nowrap}.md-source:hover{opacity:.7}.md-source__icon{display:inline-block;height:2.4rem;vertical-align:middle;width:2rem}[dir=ltr] .md-source__icon svg{margin-left:.6rem}[dir=rtl] .md-source__icon svg{margin-right:.6rem}.md-source__icon svg{margin-top:.6rem}[dir=ltr] .md-source__icon+.md-source__repository{padding-left:2rem}[dir=rtl] .md-source__icon+.md-source__repository{padding-right:2rem}[dir=ltr] .md-source__icon+.md-source__repository{margin-left:-2rem}[dir=rtl] .md-source__icon+.md-source__repository{margin-right:-2rem}[dir=ltr] .md-source__repository{margin-left:.6rem}[dir=rtl] .md-source__repository{margin-right:.6rem}.md-source__repository{display:inline-block;max-width:calc(100% - 1.2rem);overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.md-source__facts{display:flex;font-size:.55rem;gap:.4rem;list-style-type:none;margin:.1rem 0 0;opacity:.75;overflow:hidden;padding:0;width:100%}.md-source__repository--active .md-source__facts{animation:facts .25s ease-in}.md-source__fact{overflow:hidden;text-overflow:ellipsis}.md-source__repository--active .md-source__fact{animation:fact .4s ease-out}[dir=ltr] .md-source__fact:before{margin-right:.1rem}[dir=rtl] .md-source__fact:before{margin-left:.1rem}.md-source__fact:before{background-color:currentcolor;content:"";display:inline-block;height:.6rem;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;vertical-align:text-top;width:.6rem}.md-source__fact:nth-child(1n+2){flex-shrink:0}.md-source__fact--version:before{-webkit-mask-image:var(--md-source-version-icon);mask-image:var(--md-source-version-icon)}.md-source__fact--stars:before{-webkit-mask-image:var(--md-source-stars-icon);mask-image:var(--md-source-stars-icon)}.md-source__fact--forks:before{-webkit-mask-image:var(--md-source-forks-icon);mask-image:var(--md-source-forks-icon)}.md-source__fact--repositories:before{-webkit-mask-image:var(--md-source-repositories-icon);mask-image:var(--md-source-repositories-icon)}.md-source-file{margin:1em 0}[dir=ltr] .md-source-file__fact{margin-right:.6rem}[dir=rtl] .md-source-file__fact{margin-left:.6rem}.md-source-file__fact{align-items:center;color:var(--md-default-fg-color--light);display:inline-flex;font-size:.68rem;gap:.3rem}.md-source-file__fact .md-icon{flex-shrink:0;margin-bottom:.05rem}[dir=ltr] .md-source-file__fact .md-author{float:left}[dir=rtl] .md-source-file__fact .md-author{float:right}.md-source-file__fact .md-author{margin-right:.2rem}.md-source-file__fact svg{width:.9rem}:root{--md-status:url('data:image/svg+xml;charset=utf-8,');--md-status--new:url('data:image/svg+xml;charset=utf-8,');--md-status--deprecated:url('data:image/svg+xml;charset=utf-8,');--md-status--encrypted:url('data:image/svg+xml;charset=utf-8,')}.md-status:after{background-color:var(--md-default-fg-color--light);content:"";display:inline-block;height:1.125em;-webkit-mask-image:var(--md-status);mask-image:var(--md-status);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;vertical-align:text-bottom;width:1.125em}.md-status:hover:after{background-color:currentcolor}.md-status--new:after{-webkit-mask-image:var(--md-status--new);mask-image:var(--md-status--new)}.md-status--deprecated:after{-webkit-mask-image:var(--md-status--deprecated);mask-image:var(--md-status--deprecated)}.md-status--encrypted:after{-webkit-mask-image:var(--md-status--encrypted);mask-image:var(--md-status--encrypted)}.md-tabs{background-color:var(--md-primary-fg-color);color:var(--md-primary-bg-color);display:block;line-height:1.3;overflow:auto;width:100%;z-index:3}@media print{.md-tabs{display:none}}@media screen and (max-width:76.234375em){.md-tabs{display:none}}.md-tabs[hidden]{pointer-events:none}[dir=ltr] .md-tabs__list{margin-left:.2rem}[dir=rtl] .md-tabs__list{margin-right:.2rem}.md-tabs__list{contain:content;display:flex;list-style:none;margin:0;overflow:auto;padding:0;scrollbar-width:none;white-space:nowrap}.md-tabs__list::-webkit-scrollbar{display:none}.md-tabs__item{height:2.4rem;padding-left:.6rem;padding-right:.6rem}.md-tabs__item--active .md-tabs__link{color:inherit;opacity:1}.md-tabs__link{backface-visibility:hidden;display:flex;font-size:.7rem;margin-top:.8rem;opacity:.7;outline-color:var(--md-accent-fg-color);outline-offset:.2rem;transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .25s}.md-tabs__link:focus,.md-tabs__link:hover{color:inherit;opacity:1}[dir=ltr] .md-tabs__link svg{margin-right:.4rem}[dir=rtl] .md-tabs__link svg{margin-left:.4rem}.md-tabs__link svg{fill:currentcolor;height:1.3em}.md-tabs__item:nth-child(2) .md-tabs__link{transition-delay:20ms}.md-tabs__item:nth-child(3) .md-tabs__link{transition-delay:40ms}.md-tabs__item:nth-child(4) .md-tabs__link{transition-delay:60ms}.md-tabs__item:nth-child(5) .md-tabs__link{transition-delay:80ms}.md-tabs__item:nth-child(6) .md-tabs__link{transition-delay:.1s}.md-tabs__item:nth-child(7) .md-tabs__link{transition-delay:.12s}.md-tabs__item:nth-child(8) .md-tabs__link{transition-delay:.14s}.md-tabs__item:nth-child(9) .md-tabs__link{transition-delay:.16s}.md-tabs__item:nth-child(10) .md-tabs__link{transition-delay:.18s}.md-tabs__item:nth-child(11) .md-tabs__link{transition-delay:.2s}.md-tabs__item:nth-child(12) .md-tabs__link{transition-delay:.22s}.md-tabs__item:nth-child(13) .md-tabs__link{transition-delay:.24s}.md-tabs__item:nth-child(14) .md-tabs__link{transition-delay:.26s}.md-tabs__item:nth-child(15) .md-tabs__link{transition-delay:.28s}.md-tabs__item:nth-child(16) .md-tabs__link{transition-delay:.3s}.md-tabs[hidden] .md-tabs__link{opacity:0;transform:translateY(50%);transition:transform 0ms .1s,opacity .1s}:root{--md-tag-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .md-tags:not([hidden]){display:inline-flex;flex-wrap:wrap;gap:.5em;margin-bottom:.75em;margin-top:-.125em}.md-typeset .md-tag{align-items:center;background:var(--md-default-fg-color--lightest);border-radius:2.4rem;display:inline-flex;font-size:.64rem;font-size:min(.8em,.64rem);font-weight:700;gap:.5em;letter-spacing:normal;line-height:1.6;padding:.3125em .78125em}.md-typeset .md-tag[href]{-webkit-tap-highlight-color:transparent;color:inherit;outline:none;transition:color 125ms,background-color 125ms}.md-typeset .md-tag[href]:focus,.md-typeset .md-tag[href]:hover{background-color:var(--md-accent-fg-color);color:var(--md-accent-bg-color)}[id]>.md-typeset .md-tag{vertical-align:text-top}.md-typeset .md-tag-shadow{opacity:.5}.md-typeset .md-tag-icon:before{background-color:var(--md-default-fg-color--lighter);content:"";display:inline-block;height:1.2em;-webkit-mask-image:var(--md-tag-icon);mask-image:var(--md-tag-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:background-color 125ms;vertical-align:text-bottom;width:1.2em}.md-typeset .md-tag-icon[href]:focus:before,.md-typeset .md-tag-icon[href]:hover:before{background-color:var(--md-accent-bg-color)}@keyframes pulse{0%{transform:scale(.95)}75%{transform:scale(1)}to{transform:scale(.95)}}:root{--md-annotation-bg-icon:url('data:image/svg+xml;charset=utf-8,');--md-annotation-icon:url('data:image/svg+xml;charset=utf-8,')}.md-tooltip{backface-visibility:hidden;background-color:var(--md-default-bg-color);border-radius:.1rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color);font-family:var(--md-text-font-family);left:clamp(var(--md-tooltip-0,0rem) + .8rem,var(--md-tooltip-x),100vw + var(--md-tooltip-0,0rem) + .8rem - var(--md-tooltip-width) - 2 * .8rem);max-width:calc(100vw - 1.6rem);opacity:0;position:absolute;top:var(--md-tooltip-y);transform:translateY(-.4rem);transition:transform 0ms .25s,opacity .25s,z-index .25s;width:var(--md-tooltip-width);z-index:0}.md-tooltip--active{opacity:1;transform:translateY(0);transition:transform .25s cubic-bezier(.1,.7,.1,1),opacity .25s,z-index 0ms;z-index:2}.md-tooltip--inline{font-weight:700;-webkit-user-select:none;user-select:none;width:auto}.md-tooltip--inline:not(.md-tooltip--active){transform:translateY(.2rem) scale(.9)}.md-tooltip--inline .md-tooltip__inner{font-size:.5rem;padding:.2rem .4rem}[hidden]+.md-tooltip--inline{display:none}.focus-visible>.md-tooltip,.md-tooltip:target{outline:var(--md-accent-fg-color) auto}.md-tooltip__inner{font-size:.64rem;padding:.8rem}.md-tooltip__inner.md-typeset>:first-child{margin-top:0}.md-tooltip__inner.md-typeset>:last-child{margin-bottom:0}.md-annotation{font-style:normal;font-weight:400;outline:none;text-align:initial;vertical-align:text-bottom;white-space:normal}[dir=rtl] .md-annotation{direction:rtl}code .md-annotation{font-family:var(--md-code-font-family);font-size:inherit}.md-annotation:not([hidden]){display:inline-block;line-height:1.25}.md-annotation__index{border-radius:.01px;cursor:pointer;display:inline-block;margin-left:.4ch;margin-right:.4ch;outline:none;overflow:hidden;position:relative;-webkit-user-select:none;user-select:none;vertical-align:text-top;z-index:0}.md-annotation .md-annotation__index{transition:z-index .25s}@media screen{.md-annotation__index{width:2.2ch}[data-md-visible]>.md-annotation__index{animation:pulse 2s infinite}.md-annotation__index:before{background:var(--md-default-bg-color);-webkit-mask-image:var(--md-annotation-bg-icon);mask-image:var(--md-annotation-bg-icon)}.md-annotation__index:after,.md-annotation__index:before{content:"";height:2.2ch;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:-.1ch;width:2.2ch;z-index:-1}.md-annotation__index:after{background-color:var(--md-default-fg-color--lighter);-webkit-mask-image:var(--md-annotation-icon);mask-image:var(--md-annotation-icon);transform:scale(1.0001);transition:background-color .25s,transform .25s}.md-tooltip--active+.md-annotation__index:after{transform:rotate(45deg)}.md-tooltip--active+.md-annotation__index:after,:hover>.md-annotation__index:after{background-color:var(--md-accent-fg-color)}}.md-tooltip--active+.md-annotation__index{animation-play-state:paused;transition-duration:0ms;z-index:2}.md-annotation__index [data-md-annotation-id]{display:inline-block}@media print{.md-annotation__index [data-md-annotation-id]{background:var(--md-default-fg-color--lighter);border-radius:2ch;color:var(--md-default-bg-color);font-weight:700;padding:0 .6ch;white-space:nowrap}.md-annotation__index [data-md-annotation-id]:after{content:attr(data-md-annotation-id)}}.md-typeset .md-annotation-list{counter-reset:annotation;list-style:none!important}.md-typeset .md-annotation-list li{position:relative}[dir=ltr] .md-typeset .md-annotation-list li:before{left:-2.125em}[dir=rtl] .md-typeset .md-annotation-list li:before{right:-2.125em}.md-typeset .md-annotation-list li:before{background:var(--md-default-fg-color--lighter);border-radius:2ch;color:var(--md-default-bg-color);content:counter(annotation);counter-increment:annotation;font-size:.8875em;font-weight:700;height:2ch;line-height:1.25;min-width:2ch;padding:0 .6ch;position:absolute;text-align:center;top:.25em}:root{--md-tooltip-width:20rem;--md-tooltip-tail:0.3rem}.md-tooltip2{backface-visibility:hidden;color:var(--md-default-fg-color);font-family:var(--md-text-font-family);opacity:0;pointer-events:none;position:absolute;top:calc(var(--md-tooltip-host-y) + var(--md-tooltip-y));transform:translateY(-.4rem);transform-origin:calc(var(--md-tooltip-host-x) + var(--md-tooltip-x)) 0;transition:transform 0ms .25s,opacity .25s,z-index .25s;width:100%;z-index:0}.md-tooltip2:before{border-left:var(--md-tooltip-tail) solid #0000;border-right:var(--md-tooltip-tail) solid #0000;content:"";display:block;left:clamp(1.5 * .8rem,var(--md-tooltip-host-x) + var(--md-tooltip-x) - var(--md-tooltip-tail),100vw - 2 * var(--md-tooltip-tail) - 1.5 * .8rem);position:absolute;z-index:1}.md-tooltip2--top:before{border-top:var(--md-tooltip-tail) solid var(--md-default-bg-color);bottom:calc(var(--md-tooltip-tail)*-1 + .025rem);filter:drop-shadow(0 1px 0 hsla(0,0%,0%,.05))}.md-tooltip2--bottom:before{border-bottom:var(--md-tooltip-tail) solid var(--md-default-bg-color);filter:drop-shadow(0 -1px 0 hsla(0,0%,0%,.05));top:calc(var(--md-tooltip-tail)*-1 + .025rem)}.md-tooltip2--active{opacity:1;transform:translateY(0);transition:transform .4s cubic-bezier(0,1,.5,1),opacity .25s,z-index 0ms;z-index:4}.md-tooltip2__inner{scrollbar-gutter:stable;background-color:var(--md-default-bg-color);border-radius:.1rem;box-shadow:var(--md-shadow-z2);left:clamp(.8rem,var(--md-tooltip-host-x) - .8rem,100vw - var(--md-tooltip-width) - .8rem);max-height:40vh;max-width:calc(100vw - 1.6rem);position:relative;scrollbar-width:thin}.md-tooltip2__inner::-webkit-scrollbar{height:.2rem;width:.2rem}.md-tooltip2__inner::-webkit-scrollbar-thumb{background-color:var(--md-default-fg-color--lighter)}.md-tooltip2__inner::-webkit-scrollbar-thumb:hover{background-color:var(--md-accent-fg-color)}[role=dialog]>.md-tooltip2__inner{font-size:.64rem;overflow:auto;padding:0 .8rem;pointer-events:auto;width:var(--md-tooltip-width)}[role=dialog]>.md-tooltip2__inner:after,[role=dialog]>.md-tooltip2__inner:before{content:"";display:block;height:.8rem;position:sticky;width:100%;z-index:10}[role=dialog]>.md-tooltip2__inner:before{background:linear-gradient(var(--md-default-bg-color),#0000 75%);top:0}[role=dialog]>.md-tooltip2__inner:after{background:linear-gradient(#0000,var(--md-default-bg-color) 75%);bottom:0}[role=tooltip]>.md-tooltip2__inner{font-size:.5rem;font-weight:700;left:clamp(.8rem,var(--md-tooltip-host-x) + var(--md-tooltip-x) - var(--md-tooltip-width)/2,100vw - var(--md-tooltip-width) - .8rem);max-width:min(100vw - 2 * .8rem,400px);padding:.2rem .4rem;-webkit-user-select:none;user-select:none;width:fit-content}.md-tooltip2__inner.md-typeset>:first-child{margin-top:0}.md-tooltip2__inner.md-typeset>:last-child{margin-bottom:0}[dir=ltr] .md-top{margin-left:50%}[dir=rtl] .md-top{margin-right:50%}.md-top{background-color:var(--md-default-bg-color);border-radius:1.6rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color--light);cursor:pointer;display:block;font-size:.7rem;outline:none;padding:.4rem .8rem;position:fixed;top:3.2rem;transform:translate(-50%);transition:color 125ms,background-color 125ms,transform 125ms cubic-bezier(.4,0,.2,1),opacity 125ms;z-index:2}@media print{.md-top{display:none}}[dir=rtl] .md-top{transform:translate(50%)}.md-top[hidden]{opacity:0;pointer-events:none;transform:translate(-50%,.2rem);transition-duration:0ms}[dir=rtl] .md-top[hidden]{transform:translate(50%,.2rem)}.md-top:focus,.md-top:hover{background-color:var(--md-accent-fg-color);color:var(--md-accent-bg-color)}.md-top svg{display:inline-block;vertical-align:-.5em}.md-top.lucide{fill:#0000;stroke:currentcolor}@keyframes hoverfix{0%{pointer-events:none}}:root{--md-version-icon:url('data:image/svg+xml;charset=utf-8,')}.md-version{flex-shrink:0;font-size:.8rem;height:2.4rem}[dir=ltr] .md-version__current{margin-left:1.4rem;margin-right:.4rem}[dir=rtl] .md-version__current{margin-left:.4rem;margin-right:1.4rem}.md-version__current{color:inherit;cursor:pointer;outline:none;position:relative;top:.05rem}[dir=ltr] .md-version__current:after{margin-left:.4rem}[dir=rtl] .md-version__current:after{margin-right:.4rem}.md-version__current:after{background-color:currentcolor;content:"";display:inline-block;height:.6rem;-webkit-mask-image:var(--md-version-icon);mask-image:var(--md-version-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:.4rem}.md-version__alias{margin-left:.3rem;opacity:.7}.md-version__list{background-color:var(--md-default-bg-color);border-radius:.1rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color);list-style-type:none;margin:.2rem .8rem;max-height:0;opacity:0;overflow:auto;padding:0;position:absolute;scroll-snap-type:y mandatory;top:.15rem;transition:max-height 0ms .5s,opacity .25s .25s;z-index:3}.md-version:focus-within .md-version__list,.md-version:hover .md-version__list{max-height:10rem;opacity:1;transition:max-height 0ms,opacity .25s}@media (hover:none),(pointer:coarse){.md-version:hover .md-version__list{animation:hoverfix .25s forwards}.md-version:focus-within .md-version__list{animation:none}}.md-version__item{line-height:1.8rem}[dir=ltr] .md-version__link{padding-left:.6rem;padding-right:1.2rem}[dir=rtl] .md-version__link{padding-left:1.2rem;padding-right:.6rem}.md-version__link{cursor:pointer;display:block;outline:none;scroll-snap-align:start;transition:color .25s,background-color .25s;white-space:nowrap;width:100%}.md-version__link:focus,.md-version__link:hover{color:var(--md-accent-fg-color)}.md-version__link:focus{background-color:var(--md-default-fg-color--lightest)}:root{--md-admonition-icon--note:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--abstract:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--info:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--tip:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--success:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--question:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--warning:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--failure:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--danger:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--bug:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--example:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--quote:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .admonition,.md-typeset details{background-color:var(--md-admonition-bg-color);border:.075rem solid #448aff;border-radius:.2rem;box-shadow:var(--md-shadow-z1);color:var(--md-admonition-fg-color);display:flow-root;font-size:.64rem;margin:1.5625em 0;padding:0 .6rem;page-break-inside:avoid;transition:box-shadow 125ms}@media print{.md-typeset .admonition,.md-typeset details{box-shadow:none}}.md-typeset .admonition:focus-within,.md-typeset details:focus-within{box-shadow:0 0 0 .2rem #448aff1a}.md-typeset .admonition>*,.md-typeset details>*{box-sizing:border-box}.md-typeset .admonition .admonition,.md-typeset .admonition details,.md-typeset details .admonition,.md-typeset details details{margin-bottom:1em;margin-top:1em}.md-typeset .admonition .md-typeset__scrollwrap,.md-typeset details .md-typeset__scrollwrap{margin:1em -.6rem}.md-typeset .admonition .md-typeset__table,.md-typeset details .md-typeset__table{padding:0 .6rem}.md-typeset .admonition>.tabbed-set:only-child,.md-typeset details>.tabbed-set:only-child{margin-top:0}html .md-typeset .admonition>:last-child,html .md-typeset details>:last-child{margin-bottom:.6rem}[dir=ltr] .md-typeset .admonition-title,[dir=ltr] .md-typeset summary{padding-left:2rem;padding-right:.6rem}[dir=rtl] .md-typeset .admonition-title,[dir=rtl] .md-typeset summary{padding-left:.6rem;padding-right:2rem}[dir=ltr] .md-typeset .admonition-title,[dir=ltr] .md-typeset summary{border-left-width:.2rem}[dir=rtl] .md-typeset .admonition-title,[dir=rtl] .md-typeset summary{border-right-width:.2rem}[dir=ltr] .md-typeset .admonition-title,[dir=ltr] .md-typeset summary{border-top-left-radius:.1rem}[dir=ltr] .md-typeset .admonition-title,[dir=ltr] .md-typeset summary,[dir=rtl] .md-typeset .admonition-title,[dir=rtl] .md-typeset summary{border-top-right-radius:.1rem}[dir=rtl] .md-typeset .admonition-title,[dir=rtl] .md-typeset summary{border-top-left-radius:.1rem}.md-typeset .admonition-title,.md-typeset summary{background-color:#448aff1a;border:none;font-weight:700;margin:0 -.6rem;padding-bottom:.4rem;padding-top:.4rem;position:relative}html .md-typeset .admonition-title:last-child,html .md-typeset summary:last-child{margin-bottom:0}[dir=ltr] .md-typeset .admonition-title:before,[dir=ltr] .md-typeset summary:before{left:.6rem}[dir=rtl] .md-typeset .admonition-title:before,[dir=rtl] .md-typeset summary:before{right:.6rem}.md-typeset .admonition-title:before,.md-typeset summary:before{background-color:#448aff;content:"";height:1rem;-webkit-mask-image:var(--md-admonition-icon--note);mask-image:var(--md-admonition-icon--note);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.625em;width:1rem}.md-typeset .admonition-title code,.md-typeset summary code{box-shadow:0 0 0 .05rem var(--md-default-fg-color--lightest)}.md-typeset .admonition.note,.md-typeset details.note{border-color:#448aff}.md-typeset .admonition.note:focus-within,.md-typeset details.note:focus-within{box-shadow:0 0 0 .2rem #448aff1a}.md-typeset .note>.admonition-title,.md-typeset .note>summary{background-color:#448aff1a}.md-typeset .note>.admonition-title:before,.md-typeset .note>summary:before{background-color:#448aff;-webkit-mask-image:var(--md-admonition-icon--note);mask-image:var(--md-admonition-icon--note)}.md-typeset .note>.admonition-title:after,.md-typeset .note>summary:after{color:#448aff}.md-typeset .admonition.abstract,.md-typeset details.abstract{border-color:#00b0ff}.md-typeset .admonition.abstract:focus-within,.md-typeset details.abstract:focus-within{box-shadow:0 0 0 .2rem #00b0ff1a}.md-typeset .abstract>.admonition-title,.md-typeset .abstract>summary{background-color:#00b0ff1a}.md-typeset .abstract>.admonition-title:before,.md-typeset .abstract>summary:before{background-color:#00b0ff;-webkit-mask-image:var(--md-admonition-icon--abstract);mask-image:var(--md-admonition-icon--abstract)}.md-typeset .abstract>.admonition-title:after,.md-typeset .abstract>summary:after{color:#00b0ff}.md-typeset .admonition.info,.md-typeset details.info{border-color:#00b8d4}.md-typeset .admonition.info:focus-within,.md-typeset details.info:focus-within{box-shadow:0 0 0 .2rem #00b8d41a}.md-typeset .info>.admonition-title,.md-typeset .info>summary{background-color:#00b8d41a}.md-typeset .info>.admonition-title:before,.md-typeset .info>summary:before{background-color:#00b8d4;-webkit-mask-image:var(--md-admonition-icon--info);mask-image:var(--md-admonition-icon--info)}.md-typeset .info>.admonition-title:after,.md-typeset .info>summary:after{color:#00b8d4}.md-typeset .admonition.tip,.md-typeset details.tip{border-color:#00bfa5}.md-typeset .admonition.tip:focus-within,.md-typeset details.tip:focus-within{box-shadow:0 0 0 .2rem #00bfa51a}.md-typeset .tip>.admonition-title,.md-typeset .tip>summary{background-color:#00bfa51a}.md-typeset .tip>.admonition-title:before,.md-typeset .tip>summary:before{background-color:#00bfa5;-webkit-mask-image:var(--md-admonition-icon--tip);mask-image:var(--md-admonition-icon--tip)}.md-typeset .tip>.admonition-title:after,.md-typeset .tip>summary:after{color:#00bfa5}.md-typeset .admonition.success,.md-typeset details.success{border-color:#00c853}.md-typeset .admonition.success:focus-within,.md-typeset details.success:focus-within{box-shadow:0 0 0 .2rem #00c8531a}.md-typeset .success>.admonition-title,.md-typeset .success>summary{background-color:#00c8531a}.md-typeset .success>.admonition-title:before,.md-typeset .success>summary:before{background-color:#00c853;-webkit-mask-image:var(--md-admonition-icon--success);mask-image:var(--md-admonition-icon--success)}.md-typeset .success>.admonition-title:after,.md-typeset .success>summary:after{color:#00c853}.md-typeset .admonition.question,.md-typeset details.question{border-color:#64dd17}.md-typeset .admonition.question:focus-within,.md-typeset details.question:focus-within{box-shadow:0 0 0 .2rem #64dd171a}.md-typeset .question>.admonition-title,.md-typeset .question>summary{background-color:#64dd171a}.md-typeset .question>.admonition-title:before,.md-typeset .question>summary:before{background-color:#64dd17;-webkit-mask-image:var(--md-admonition-icon--question);mask-image:var(--md-admonition-icon--question)}.md-typeset .question>.admonition-title:after,.md-typeset .question>summary:after{color:#64dd17}.md-typeset .admonition.warning,.md-typeset details.warning{border-color:#ff9100}.md-typeset .admonition.warning:focus-within,.md-typeset details.warning:focus-within{box-shadow:0 0 0 .2rem #ff91001a}.md-typeset .warning>.admonition-title,.md-typeset .warning>summary{background-color:#ff91001a}.md-typeset .warning>.admonition-title:before,.md-typeset .warning>summary:before{background-color:#ff9100;-webkit-mask-image:var(--md-admonition-icon--warning);mask-image:var(--md-admonition-icon--warning)}.md-typeset .warning>.admonition-title:after,.md-typeset .warning>summary:after{color:#ff9100}.md-typeset .admonition.failure,.md-typeset details.failure{border-color:#ff5252}.md-typeset .admonition.failure:focus-within,.md-typeset details.failure:focus-within{box-shadow:0 0 0 .2rem #ff52521a}.md-typeset .failure>.admonition-title,.md-typeset .failure>summary{background-color:#ff52521a}.md-typeset .failure>.admonition-title:before,.md-typeset .failure>summary:before{background-color:#ff5252;-webkit-mask-image:var(--md-admonition-icon--failure);mask-image:var(--md-admonition-icon--failure)}.md-typeset .failure>.admonition-title:after,.md-typeset .failure>summary:after{color:#ff5252}.md-typeset .admonition.danger,.md-typeset details.danger{border-color:#ff1744}.md-typeset .admonition.danger:focus-within,.md-typeset details.danger:focus-within{box-shadow:0 0 0 .2rem #ff17441a}.md-typeset .danger>.admonition-title,.md-typeset .danger>summary{background-color:#ff17441a}.md-typeset .danger>.admonition-title:before,.md-typeset .danger>summary:before{background-color:#ff1744;-webkit-mask-image:var(--md-admonition-icon--danger);mask-image:var(--md-admonition-icon--danger)}.md-typeset .danger>.admonition-title:after,.md-typeset .danger>summary:after{color:#ff1744}.md-typeset .admonition.bug,.md-typeset details.bug{border-color:#f50057}.md-typeset .admonition.bug:focus-within,.md-typeset details.bug:focus-within{box-shadow:0 0 0 .2rem #f500571a}.md-typeset .bug>.admonition-title,.md-typeset .bug>summary{background-color:#f500571a}.md-typeset .bug>.admonition-title:before,.md-typeset .bug>summary:before{background-color:#f50057;-webkit-mask-image:var(--md-admonition-icon--bug);mask-image:var(--md-admonition-icon--bug)}.md-typeset .bug>.admonition-title:after,.md-typeset .bug>summary:after{color:#f50057}.md-typeset .admonition.example,.md-typeset details.example{border-color:#7c4dff}.md-typeset .admonition.example:focus-within,.md-typeset details.example:focus-within{box-shadow:0 0 0 .2rem #7c4dff1a}.md-typeset .example>.admonition-title,.md-typeset .example>summary{background-color:#7c4dff1a}.md-typeset .example>.admonition-title:before,.md-typeset .example>summary:before{background-color:#7c4dff;-webkit-mask-image:var(--md-admonition-icon--example);mask-image:var(--md-admonition-icon--example)}.md-typeset .example>.admonition-title:after,.md-typeset .example>summary:after{color:#7c4dff}.md-typeset .admonition.quote,.md-typeset details.quote{border-color:#9e9e9e}.md-typeset .admonition.quote:focus-within,.md-typeset details.quote:focus-within{box-shadow:0 0 0 .2rem #9e9e9e1a}.md-typeset .quote>.admonition-title,.md-typeset .quote>summary{background-color:#9e9e9e1a}.md-typeset .quote>.admonition-title:before,.md-typeset .quote>summary:before{background-color:#9e9e9e;-webkit-mask-image:var(--md-admonition-icon--quote);mask-image:var(--md-admonition-icon--quote)}.md-typeset .quote>.admonition-title:after,.md-typeset .quote>summary:after{color:#9e9e9e}:root{--md-footnotes-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .footnote{color:var(--md-default-fg-color--light);font-size:.64rem}[dir=ltr] .md-typeset .footnote>ol{margin-left:0}[dir=rtl] .md-typeset .footnote>ol{margin-right:0}.md-typeset .footnote>ol>li{transition:color 125ms}.md-typeset .footnote>ol>li:target{color:var(--md-default-fg-color)}.md-typeset .footnote>ol>li:focus-within .footnote-backref{opacity:1;transform:translateX(0);transition:none}.md-typeset .footnote>ol>li:hover .footnote-backref,.md-typeset .footnote>ol>li:target .footnote-backref{opacity:1;transform:translateX(0)}.md-typeset .footnote>ol>li>:first-child{margin-top:0}.md-typeset .footnote-ref{font-size:.75em;font-weight:700}html .md-typeset .footnote-ref{outline-offset:.1rem}.md-typeset [id^="fnref:"]:target>.footnote-ref{outline:auto}.md-typeset .footnote-backref{color:var(--md-typeset-a-color);display:inline-block;font-size:0;opacity:0;transform:translateX(.25rem);transition:color .25s,transform .25s .25s,opacity 125ms .25s;vertical-align:text-bottom}@media print{.md-typeset .footnote-backref{color:var(--md-typeset-a-color);opacity:1;transform:translateX(0)}}[dir=rtl] .md-typeset .footnote-backref{transform:translateX(-.25rem)}.md-typeset .footnote-backref:hover{color:var(--md-accent-fg-color)}.md-typeset .footnote-backref:before{background-color:currentcolor;content:"";display:inline-block;height:.8rem;-webkit-mask-image:var(--md-footnotes-icon);mask-image:var(--md-footnotes-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:.8rem}[dir=rtl] .md-typeset .footnote-backref:before{transform:scaleX(-1)}[dir=ltr] .md-typeset .headerlink{margin-left:.5rem}[dir=rtl] .md-typeset .headerlink{margin-right:.5rem}.md-typeset .headerlink{color:var(--md-default-fg-color--lighter);display:inline-block;opacity:0;transition:color .25s,opacity 125ms}@media print{.md-typeset .headerlink{display:none}}.md-typeset .headerlink:focus,.md-typeset :hover>.headerlink,.md-typeset :target>.headerlink{opacity:1;transition:color .25s,opacity 125ms}.md-typeset .headerlink:focus,.md-typeset .headerlink:hover,.md-typeset :target>.headerlink{color:var(--md-accent-fg-color)}.md-typeset :target{--md-scroll-margin:3.6rem;--md-scroll-offset:0rem;scroll-margin-top:calc(var(--md-scroll-margin) - var(--md-scroll-offset))}@media screen and (min-width:76.25em){.md-header--lifted~.md-container .md-typeset :target{--md-scroll-margin:6rem}}.md-typeset h1:target,.md-typeset h2:target,.md-typeset h3:target{--md-scroll-offset:0.2rem}.md-typeset h4:target{--md-scroll-offset:0.15rem}.doc-contents td code{word-break:normal!important}.doc-md-description,.doc-md-description>p:first-child{display:inline}.md-typeset h5 .doc-object-name{text-transform:none}.doc .md-typeset__table,.doc .md-typeset__table table{display:table!important;width:100%}.doc .md-typeset__table tr{display:table-row}.doc-param-default,.doc-type_param-default{float:right}.doc-heading-parameter,.doc-heading-type_parameter{display:inline}.md-typeset .doc-heading-parameter{font-size:inherit}.doc-heading-parameter .headerlink,.doc-heading-type_parameter .headerlink{margin-left:0!important;margin-right:.2rem}.doc-section-title{font-weight:700}.doc-signature .autorefs{color:inherit;text-decoration-style:dotted}:host,:root,[data-md-color-scheme=default]{--doc-symbol-parameter-fg-color:#829bd1;--doc-symbol-type_parameter-fg-color:#829bd1;--doc-symbol-attribute-fg-color:#953800;--doc-symbol-function-fg-color:#8250df;--doc-symbol-method-fg-color:#8250df;--doc-symbol-class-fg-color:#0550ae;--doc-symbol-type_alias-fg-color:#0550ae;--doc-symbol-module-fg-color:#5cad0f;--doc-symbol-parameter-bg-color:#829bd11a;--doc-symbol-type_parameter-bg-color:#829bd11a;--doc-symbol-attribute-bg-color:#9538001a;--doc-symbol-function-bg-color:#8250df1a;--doc-symbol-method-bg-color:#8250df1a;--doc-symbol-class-bg-color:#0550ae1a;--doc-symbol-type_alias-bg-color:#0550ae1a;--doc-symbol-module-bg-color:#5cad0f1a}[data-md-color-scheme=slate]{--doc-symbol-parameter-fg-color:#829bd1;--doc-symbol-type_parameter-fg-color:#829bd1;--doc-symbol-attribute-fg-color:#ffa657;--doc-symbol-function-fg-color:#d2a8ff;--doc-symbol-method-fg-color:#d2a8ff;--doc-symbol-class-fg-color:#79c0ff;--doc-symbol-type_alias-fg-color:#79c0ff;--doc-symbol-module-fg-color:#baff79;--doc-symbol-parameter-bg-color:#829bd11a;--doc-symbol-type_parameter-bg-color:#829bd11a;--doc-symbol-attribute-bg-color:#ffa6571a;--doc-symbol-function-bg-color:#d2a8ff1a;--doc-symbol-method-bg-color:#d2a8ff1a;--doc-symbol-class-bg-color:#79c0ff1a;--doc-symbol-type_alias-bg-color:#79c0ff1a;--doc-symbol-module-bg-color:#baff791a}code.doc-symbol{border-radius:.1rem;font-size:.85em;font-weight:700;padding:0 .3em}a code.doc-symbol-parameter,code.doc-symbol-parameter{background-color:var(--doc-symbol-parameter-bg-color);color:var(--doc-symbol-parameter-fg-color)}code.doc-symbol-parameter:after{content:"param"}a code.doc-symbol-type_parameter,code.doc-symbol-type_parameter{background-color:var(--doc-symbol-type_parameter-bg-color);color:var(--doc-symbol-type_parameter-fg-color)}code.doc-symbol-type_parameter:after{content:"type-param"}a code.doc-symbol-attribute,code.doc-symbol-attribute{background-color:var(--doc-symbol-attribute-bg-color);color:var(--doc-symbol-attribute-fg-color)}code.doc-symbol-attribute:after{content:"attr"}a code.doc-symbol-function,code.doc-symbol-function{background-color:var(--doc-symbol-function-bg-color);color:var(--doc-symbol-function-fg-color)}code.doc-symbol-function:after{content:"func"}a code.doc-symbol-method,code.doc-symbol-method{background-color:var(--doc-symbol-method-bg-color);color:var(--doc-symbol-method-fg-color)}code.doc-symbol-method:after{content:"meth"}a code.doc-symbol-class,code.doc-symbol-class{background-color:var(--doc-symbol-class-bg-color);color:var(--doc-symbol-class-fg-color)}code.doc-symbol-class:after{content:"class"}a code.doc-symbol-type_alias,code.doc-symbol-type_alias{background-color:var(--doc-symbol-type_alias-bg-color);color:var(--doc-symbol-type_alias-fg-color)}code.doc-symbol-type_alias:after{content:"type"}a code.doc-symbol-module,code.doc-symbol-module{background-color:var(--doc-symbol-module-bg-color);color:var(--doc-symbol-module-fg-color)}code.doc-symbol-module:after{content:"mod"}:root{--md-admonition-icon--mkdocstrings-source:url('data:image/svg+xml;charset=utf-8,') }.md-typeset .admonition.mkdocstrings-source,.md-typeset details.mkdocstrings-source{border:none;padding:0}.md-typeset .admonition.mkdocstrings-source:focus-within,.md-typeset details.mkdocstrings-source:focus-within{box-shadow:none}.md-typeset .mkdocstrings-source>.admonition-title,.md-typeset .mkdocstrings-source>summary{background-color:inherit}.md-typeset .mkdocstrings-source>.admonition-title:before,.md-typeset .mkdocstrings-source>summary:before{background-color:var(--md-default-fg-color);-webkit-mask-image:var(--md-admonition-icon--mkdocstrings-source);mask-image:var(--md-admonition-icon--mkdocstrings-source)}.md-typeset div.arithmatex{overflow:auto}@media screen and (max-width:44.984375em){.md-typeset div.arithmatex{margin:0 -.8rem}.md-typeset div.arithmatex>*{width:min-content}}.md-typeset div.arithmatex>*{margin-left:auto!important;margin-right:auto!important;padding:0 .8rem;touch-action:auto}.md-typeset div.arithmatex>* mjx-container{margin:0!important}.md-typeset div.arithmatex mjx-assistive-mml{height:0}.md-typeset del.critic{background-color:var(--md-typeset-del-color)}.md-typeset del.critic,.md-typeset ins.critic{-webkit-box-decoration-break:clone;box-decoration-break:clone}.md-typeset ins.critic{background-color:var(--md-typeset-ins-color)}.md-typeset .critic.comment{-webkit-box-decoration-break:clone;box-decoration-break:clone;color:var(--md-code-hl-comment-color)}.md-typeset .critic.comment:before{content:"/* "}.md-typeset .critic.comment:after{content:" */"}.md-typeset .critic.block{box-shadow:none;display:block;margin:1em 0;overflow:auto;padding-left:.8rem;padding-right:.8rem}.md-typeset .critic.block>:first-child{margin-top:.5em}.md-typeset .critic.block>:last-child{margin-bottom:.5em}:root{--md-details-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset details{display:flow-root;overflow:visible;padding-top:0}.md-typeset details[open]>summary:after{transform:rotate(90deg)}.md-typeset details:not([open]){box-shadow:none;padding-bottom:0}.md-typeset details:not([open])>summary{border-radius:.1rem}[dir=ltr] .md-typeset summary{padding-right:1.8rem}[dir=rtl] .md-typeset summary{padding-left:1.8rem}[dir=ltr] .md-typeset summary{border-top-left-radius:.1rem}[dir=ltr] .md-typeset summary,[dir=rtl] .md-typeset summary{border-top-right-radius:.1rem}[dir=rtl] .md-typeset summary{border-top-left-radius:.1rem}.md-typeset summary{cursor:pointer;display:block;min-height:1rem;overflow:hidden}.md-typeset summary.focus-visible{outline-color:var(--md-accent-fg-color);outline-offset:.2rem}.md-typeset summary:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}[dir=ltr] .md-typeset summary:after{right:.4rem}[dir=rtl] .md-typeset summary:after{left:.4rem}.md-typeset summary:after{background-color:currentcolor;content:"";height:1rem;-webkit-mask-image:var(--md-details-icon);mask-image:var(--md-details-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.625em;transform:rotate(0deg);transition:transform .25s;width:1rem}[dir=rtl] .md-typeset summary:after{transform:rotate(180deg)}.md-typeset summary::marker{display:none}.md-typeset summary::-webkit-details-marker{display:none}.md-typeset .emojione,.md-typeset .gemoji,.md-typeset .twemoji{--md-icon-size:1.125em;display:inline-flex;height:var(--md-icon-size);vertical-align:text-top}.md-typeset .emojione svg,.md-typeset .gemoji svg,.md-typeset .twemoji svg{fill:currentcolor;max-height:100%;width:var(--md-icon-size)}.md-typeset .emojione svg.lucide,.md-typeset .gemoji svg.lucide,.md-typeset .twemoji svg.lucide{fill:#0000;stroke:currentcolor}.md-typeset .lg,.md-typeset .xl,.md-typeset .xxl,.md-typeset .xxxl{vertical-align:text-bottom}.md-typeset .middle{vertical-align:middle}.md-typeset .lg{--md-icon-size:1.5em}.md-typeset .xl{--md-icon-size:2.25em}.md-typeset .xxl{--md-icon-size:3em}.md-typeset .xxxl{--md-icon-size:4em}.highlight .o,.highlight .ow{color:var(--md-code-hl-operator-color)}.highlight .p{color:var(--md-code-hl-punctuation-color)}.highlight .cpf,.highlight .l,.highlight .s,.highlight .s1,.highlight .s2,.highlight .sb,.highlight .sc,.highlight .si,.highlight .ss{color:var(--md-code-hl-string-color)}.highlight .cp,.highlight .se,.highlight .sh,.highlight .sr,.highlight .sx{color:var(--md-code-hl-special-color)}.highlight .il,.highlight .m,.highlight .mb,.highlight .mf,.highlight .mh,.highlight .mi,.highlight .mo{color:var(--md-code-hl-number-color)}.highlight .k,.highlight .kd,.highlight .kn,.highlight .kp,.highlight .kr,.highlight .kt{color:var(--md-code-hl-keyword-color)}.highlight .kc,.highlight .n{color:var(--md-code-hl-name-color)}.highlight .bp,.highlight .nb,.highlight .no{color:var(--md-code-hl-constant-color)}.highlight .nc,.highlight .ne,.highlight .nf,.highlight .nn{color:var(--md-code-hl-function-color)}.highlight .nd,.highlight .ni,.highlight .nl,.highlight .nt{color:var(--md-code-hl-keyword-color)}.highlight .c,.highlight .c1,.highlight .ch,.highlight .cm,.highlight .cs,.highlight .sd{color:var(--md-code-hl-comment-color)}.highlight .na,.highlight .nv,.highlight .vc,.highlight .vg,.highlight .vi{color:var(--md-code-hl-variable-color)}.highlight .ge,.highlight .gh,.highlight .go,.highlight .gp,.highlight .gr,.highlight .gs,.highlight .gt,.highlight .gu{color:var(--md-code-hl-generic-color)}.highlight .gd,.highlight .gi{border-radius:.1rem;margin:0 -.125em;padding:0 .125em}.highlight .gd{background-color:var(--md-typeset-del-color)}.highlight .gi{background-color:var(--md-typeset-ins-color)}.highlight .hll{background-color:var(--md-code-hl-color--light);box-shadow:2px 0 0 0 var(--md-code-hl-color) inset;display:block;margin:0 -1.1764705882em;padding:0 1.1764705882em}.highlight span.filename{background-color:var(--md-code-bg-color);border-bottom:.05rem solid var(--md-default-fg-color--lightest);border-top-left-radius:.1rem;border-top-right-radius:.1rem;display:flow-root;font-size:.85em;font-weight:700;margin-top:1em;padding:.6617647059em 1.1764705882em;position:relative}.highlight span.filename+pre{margin-top:0}.highlight span.filename+pre>code{border-top-left-radius:0;border-top-right-radius:0}.highlight [data-linenos]:before{background-color:var(--md-code-bg-color);box-shadow:-.05rem 0 var(--md-default-fg-color--lightest) inset;color:var(--md-default-fg-color--light);content:attr(data-linenos);float:left;left:-1.1764705882em;margin-left:-1.1764705882em;margin-right:1.1764705882em;padding-left:1.1764705882em;position:sticky;-webkit-user-select:none;user-select:none;z-index:3}.highlight code>span[id^=__span]>:last-child .md-annotation{margin-right:2.4rem}.highlight code[data-md-copying]{display:initial}.highlight code[data-md-copying] .hll{display:contents}.highlight code[data-md-copying] .md-annotation{display:none}.highlighttable{display:flow-root}.highlighttable tbody,.highlighttable td{display:block;padding:0}.highlighttable tr{display:flex}.highlighttable pre{margin:0}.highlighttable th.filename{flex-grow:1;padding:0;text-align:left}.highlighttable th.filename span.filename{margin-top:0}.highlighttable .linenos{background-color:var(--md-code-bg-color);border-bottom-left-radius:.1rem;border-top-left-radius:.1rem;font-size:.85em;padding:.7720588235em 0 .7720588235em 1.1764705882em;-webkit-user-select:none;user-select:none}.highlighttable .linenodiv{box-shadow:-.05rem 0 var(--md-default-fg-color--lightest) inset}.highlighttable .linenodiv pre{color:var(--md-default-fg-color--light);text-align:right}.highlighttable .linenodiv span[class]{padding-right:.5882352941em}.highlighttable .code{flex:1;min-width:0}.linenodiv a{color:inherit}.md-typeset .highlighttable{direction:ltr;margin:1em 0}.md-typeset .highlighttable>tbody>tr>.code>div>pre>code{border-bottom-left-radius:0;border-top-left-radius:0}.md-typeset .highlight+.result{border:.05rem solid var(--md-code-bg-color);border-bottom-left-radius:.1rem;border-bottom-right-radius:.1rem;border-top-width:.1rem;margin-top:-1.125em;overflow:visible;padding:0 1em}.md-typeset .highlight+.result:after{clear:both;content:"";display:block}@media screen and (max-width:44.984375em){.md-content__inner>.highlight{margin:1em -.8rem}.md-content__inner>.highlight>.filename,.md-content__inner>.highlight>.highlighttable>tbody>tr>.code>div>pre>code,.md-content__inner>.highlight>.highlighttable>tbody>tr>.filename span.filename,.md-content__inner>.highlight>.highlighttable>tbody>tr>.linenos,.md-content__inner>.highlight>pre>code{border-radius:0}.md-content__inner>.highlight+.result{border-left-width:0;border-radius:0;border-right-width:0;margin-left:-.8rem;margin-right:-.8rem}}.md-typeset .keys kbd:after,.md-typeset .keys kbd:before{-moz-osx-font-smoothing:initial;-webkit-font-smoothing:initial;color:inherit;margin:0;position:relative}.md-typeset .keys span{color:var(--md-default-fg-color--light);padding:0 .2em}.md-typeset .keys .key-alt:before,.md-typeset .keys .key-left-alt:before,.md-typeset .keys .key-right-alt:before{content:"⎇";padding-right:.4em}.md-typeset .keys .key-command:before,.md-typeset .keys .key-left-command:before,.md-typeset .keys .key-right-command:before{content:"⌘";padding-right:.4em}.md-typeset .keys .key-control:before,.md-typeset .keys .key-left-control:before,.md-typeset .keys .key-right-control:before{content:"⌃";padding-right:.4em}.md-typeset .keys .key-left-meta:before,.md-typeset .keys .key-meta:before,.md-typeset .keys .key-right-meta:before{content:"◆";padding-right:.4em}.md-typeset .keys .key-left-option:before,.md-typeset .keys .key-option:before,.md-typeset .keys .key-right-option:before{content:"⌥";padding-right:.4em}.md-typeset .keys .key-left-shift:before,.md-typeset .keys .key-right-shift:before,.md-typeset .keys .key-shift:before{content:"⇧";padding-right:.4em}.md-typeset .keys .key-left-super:before,.md-typeset .keys .key-right-super:before,.md-typeset .keys .key-super:before{content:"❖";padding-right:.4em}.md-typeset .keys .key-left-windows:before,.md-typeset .keys .key-right-windows:before,.md-typeset .keys .key-windows:before{content:"⊞";padding-right:.4em}.md-typeset .keys .key-arrow-down:before{content:"↓";padding-right:.4em}.md-typeset .keys .key-arrow-left:before{content:"←";padding-right:.4em}.md-typeset .keys .key-arrow-right:before{content:"→";padding-right:.4em}.md-typeset .keys .key-arrow-up:before{content:"↑";padding-right:.4em}.md-typeset .keys .key-backspace:before{content:"⌫";padding-right:.4em}.md-typeset .keys .key-backtab:before{content:"⇤";padding-right:.4em}.md-typeset .keys .key-caps-lock:before{content:"⇪";padding-right:.4em}.md-typeset .keys .key-clear:before{content:"⌧";padding-right:.4em}.md-typeset .keys .key-context-menu:before{content:"☰";padding-right:.4em}.md-typeset .keys .key-delete:before{content:"⌦";padding-right:.4em}.md-typeset .keys .key-eject:before{content:"⏏";padding-right:.4em}.md-typeset .keys .key-end:before{content:"⤓";padding-right:.4em}.md-typeset .keys .key-escape:before{content:"⎋";padding-right:.4em}.md-typeset .keys .key-home:before{content:"⤒";padding-right:.4em}.md-typeset .keys .key-insert:before{content:"⎀";padding-right:.4em}.md-typeset .keys .key-page-down:before{content:"⇟";padding-right:.4em}.md-typeset .keys .key-page-up:before{content:"⇞";padding-right:.4em}.md-typeset .keys .key-print-screen:before{content:"⎙";padding-right:.4em}.md-typeset .keys .key-tab:after{content:"⇥";padding-left:.4em}.md-typeset .keys .key-num-enter:after{content:"⌤";padding-left:.4em}.md-typeset .keys .key-enter:after{content:"⏎";padding-left:.4em}:root{--md-tabbed-icon--prev:url('data:image/svg+xml;charset=utf-8,');--md-tabbed-icon--next:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .tabbed-set{border-radius:.1rem;display:flex;flex-flow:column wrap;margin:1em 0;position:relative}.md-typeset .tabbed-set>input{height:0;opacity:0;position:absolute;width:0}.md-typeset .tabbed-set>input:target{--md-scroll-offset:0.625em}.md-typeset .tabbed-set>input.focus-visible~.tabbed-labels:before{background-color:var(--md-accent-fg-color)}.md-typeset .tabbed-labels{-ms-overflow-style:none;box-shadow:0 -.05rem var(--md-default-fg-color--lightest) inset;display:flex;max-width:100%;overflow:auto;scrollbar-width:none}@media print{.md-typeset .tabbed-labels{display:contents}}@media screen{.js .md-typeset .tabbed-labels{position:relative}.js .md-typeset .tabbed-labels:before{background:var(--md-default-fg-color);bottom:0;content:"";display:block;height:2px;left:0;position:absolute;transform:translateX(var(--md-indicator-x));transition:width 225ms,background-color .25s,transform .25s;transition-timing-function:cubic-bezier(.4,0,.2,1);width:var(--md-indicator-width)}}.md-typeset .tabbed-labels::-webkit-scrollbar{display:none}.md-typeset .tabbed-labels>label{border-bottom:.1rem solid #0000;border-radius:.1rem .1rem 0 0;color:var(--md-default-fg-color--light);cursor:pointer;flex-shrink:0;font-size:.64rem;font-weight:700;padding:.78125em 1.25em .625em;scroll-margin-inline-start:1rem;transition:background-color .25s,color .25s;white-space:nowrap;width:auto}@media print{.md-typeset .tabbed-labels>label:first-child{order:1}.md-typeset .tabbed-labels>label:nth-child(2){order:2}.md-typeset .tabbed-labels>label:nth-child(3){order:3}.md-typeset .tabbed-labels>label:nth-child(4){order:4}.md-typeset .tabbed-labels>label:nth-child(5){order:5}.md-typeset .tabbed-labels>label:nth-child(6){order:6}.md-typeset .tabbed-labels>label:nth-child(7){order:7}.md-typeset .tabbed-labels>label:nth-child(8){order:8}.md-typeset .tabbed-labels>label:nth-child(9){order:9}.md-typeset .tabbed-labels>label:nth-child(10){order:10}.md-typeset .tabbed-labels>label:nth-child(11){order:11}.md-typeset .tabbed-labels>label:nth-child(12){order:12}.md-typeset .tabbed-labels>label:nth-child(13){order:13}.md-typeset .tabbed-labels>label:nth-child(14){order:14}.md-typeset .tabbed-labels>label:nth-child(15){order:15}.md-typeset .tabbed-labels>label:nth-child(16){order:16}.md-typeset .tabbed-labels>label:nth-child(17){order:17}.md-typeset .tabbed-labels>label:nth-child(18){order:18}.md-typeset .tabbed-labels>label:nth-child(19){order:19}.md-typeset .tabbed-labels>label:nth-child(20){order:20}}.md-typeset .tabbed-labels>label:hover{color:var(--md-default-fg-color)}.md-typeset .tabbed-labels>label>[href]:first-child{color:inherit}.md-typeset .tabbed-labels--linked>label{padding:0}.md-typeset .tabbed-labels--linked>label>a{display:block;padding:.78125em 1.25em .625em}.md-typeset .tabbed-content{width:100%}@media print{.md-typeset .tabbed-content{display:contents}}.md-typeset .tabbed-block{display:none}@media print{.md-typeset .tabbed-block{display:block}.md-typeset .tabbed-block:first-child{order:1}.md-typeset .tabbed-block:nth-child(2){order:2}.md-typeset .tabbed-block:nth-child(3){order:3}.md-typeset .tabbed-block:nth-child(4){order:4}.md-typeset .tabbed-block:nth-child(5){order:5}.md-typeset .tabbed-block:nth-child(6){order:6}.md-typeset .tabbed-block:nth-child(7){order:7}.md-typeset .tabbed-block:nth-child(8){order:8}.md-typeset .tabbed-block:nth-child(9){order:9}.md-typeset .tabbed-block:nth-child(10){order:10}.md-typeset .tabbed-block:nth-child(11){order:11}.md-typeset .tabbed-block:nth-child(12){order:12}.md-typeset .tabbed-block:nth-child(13){order:13}.md-typeset .tabbed-block:nth-child(14){order:14}.md-typeset .tabbed-block:nth-child(15){order:15}.md-typeset .tabbed-block:nth-child(16){order:16}.md-typeset .tabbed-block:nth-child(17){order:17}.md-typeset .tabbed-block:nth-child(18){order:18}.md-typeset .tabbed-block:nth-child(19){order:19}.md-typeset .tabbed-block:nth-child(20){order:20}}.md-typeset .tabbed-block>.highlight:first-child>pre,.md-typeset .tabbed-block>pre:first-child{margin:0}.md-typeset .tabbed-block>.highlight:first-child>pre>code,.md-typeset .tabbed-block>pre:first-child>code{border-top-left-radius:0;border-top-right-radius:0}.md-typeset .tabbed-block>.highlight:first-child>.filename{border-top-left-radius:0;border-top-right-radius:0;margin:0}.md-typeset .tabbed-block>.highlight:first-child>.highlighttable{margin:0}.md-typeset .tabbed-block>.highlight:first-child>.highlighttable>tbody>tr>.filename span.filename,.md-typeset .tabbed-block>.highlight:first-child>.highlighttable>tbody>tr>.linenos{border-top-left-radius:0;border-top-right-radius:0;margin:0}.md-typeset .tabbed-block>.highlight:first-child>.highlighttable>tbody>tr>.code>div>pre>code{border-top-left-radius:0;border-top-right-radius:0}.md-typeset .tabbed-block>.highlight:first-child+.result{margin-top:-.125em}.md-typeset .tabbed-block>.tabbed-set{margin:0}.md-typeset .tabbed-button{align-self:center;border-radius:100%;color:var(--md-default-fg-color--light);cursor:pointer;display:block;height:.9rem;margin-top:.1rem;pointer-events:auto;transition:background-color .25s;width:.9rem}.md-typeset .tabbed-button:hover{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}.md-typeset .tabbed-button:after{background-color:currentcolor;content:"";display:block;height:100%;-webkit-mask-image:var(--md-tabbed-icon--prev);mask-image:var(--md-tabbed-icon--prev);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:background-color .25s,transform .25s;width:100%}.md-typeset .tabbed-control{background:linear-gradient(to right,var(--md-default-bg-color) 60%,#0000);display:flex;height:1.9rem;justify-content:start;pointer-events:none;position:absolute;transition:opacity 125ms;width:1.2rem}[dir=rtl] .md-typeset .tabbed-control{transform:rotate(180deg)}.md-typeset .tabbed-control[hidden]{opacity:0}.md-typeset .tabbed-control--next{background:linear-gradient(to left,var(--md-default-bg-color) 60%,#0000);justify-content:end;right:0}.md-typeset .tabbed-control--next .tabbed-button:after{-webkit-mask-image:var(--md-tabbed-icon--next);mask-image:var(--md-tabbed-icon--next)}@media screen and (max-width:44.984375em){[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels{padding-left:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels{padding-right:.8rem}.md-content__inner>.tabbed-set .tabbed-labels{margin:0 -.8rem;max-width:100vw;scroll-padding-inline-start:.8rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels:after{padding-right:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels:after{padding-left:.8rem}.md-content__inner>.tabbed-set .tabbed-labels:after{content:""}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{padding-left:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{padding-right:.8rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{margin-left:-.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{margin-right:-.8rem}.md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{width:2rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{padding-right:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{padding-left:.8rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{margin-right:-.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{margin-left:-.8rem}.md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{width:2rem}}@media screen{.md-typeset .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9){color:var(--md-default-fg-color)}.md-typeset .no-js .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.md-typeset .no-js .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.md-typeset .no-js .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.md-typeset .no-js .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.md-typeset .no-js .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.md-typeset .no-js .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.md-typeset .no-js .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.md-typeset .no-js .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.md-typeset .no-js .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.md-typeset .no-js .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.md-typeset .no-js .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.md-typeset .no-js .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.md-typeset .no-js .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.md-typeset .no-js .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.md-typeset .no-js .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.md-typeset .no-js .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.md-typeset .no-js .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.md-typeset .no-js .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.md-typeset .no-js .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.md-typeset .no-js .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),.md-typeset [role=dialog] .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.md-typeset [role=dialog] .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.md-typeset [role=dialog] .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.md-typeset [role=dialog] .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.md-typeset [role=dialog] .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.md-typeset [role=dialog] .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.md-typeset [role=dialog] .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.md-typeset [role=dialog] .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.md-typeset [role=dialog] .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.md-typeset [role=dialog] .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.md-typeset [role=dialog] .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.md-typeset [role=dialog] .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.md-typeset [role=dialog] .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.md-typeset [role=dialog] .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.md-typeset [role=dialog] .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.md-typeset [role=dialog] .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.md-typeset [role=dialog] .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.md-typeset [role=dialog] .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.md-typeset [role=dialog] .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.md-typeset [role=dialog] .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),.no-js .md-typeset .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.no-js .md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.no-js .md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.no-js .md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.no-js .md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.no-js .md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.no-js .md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.no-js .md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.no-js .md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.no-js .md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.no-js .md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.no-js .md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.no-js .md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.no-js .md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.no-js .md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.no-js .md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.no-js .md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.no-js .md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.no-js .md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.no-js .md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),[role=dialog] .md-typeset .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,[role=dialog] .md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),[role=dialog] .md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),[role=dialog] .md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),[role=dialog] .md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),[role=dialog] .md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),[role=dialog] .md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),[role=dialog] .md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),[role=dialog] .md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),[role=dialog] .md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),[role=dialog] .md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),[role=dialog] .md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),[role=dialog] .md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),[role=dialog] .md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),[role=dialog] .md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),[role=dialog] .md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),[role=dialog] .md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),[role=dialog] .md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),[role=dialog] .md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),[role=dialog] .md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9){border-color:var(--md-default-fg-color)}}.md-typeset .tabbed-set>input:first-child.focus-visible~.tabbed-labels>:first-child,.md-typeset .tabbed-set>input:nth-child(10).focus-visible~.tabbed-labels>:nth-child(10),.md-typeset .tabbed-set>input:nth-child(11).focus-visible~.tabbed-labels>:nth-child(11),.md-typeset .tabbed-set>input:nth-child(12).focus-visible~.tabbed-labels>:nth-child(12),.md-typeset .tabbed-set>input:nth-child(13).focus-visible~.tabbed-labels>:nth-child(13),.md-typeset .tabbed-set>input:nth-child(14).focus-visible~.tabbed-labels>:nth-child(14),.md-typeset .tabbed-set>input:nth-child(15).focus-visible~.tabbed-labels>:nth-child(15),.md-typeset .tabbed-set>input:nth-child(16).focus-visible~.tabbed-labels>:nth-child(16),.md-typeset .tabbed-set>input:nth-child(17).focus-visible~.tabbed-labels>:nth-child(17),.md-typeset .tabbed-set>input:nth-child(18).focus-visible~.tabbed-labels>:nth-child(18),.md-typeset .tabbed-set>input:nth-child(19).focus-visible~.tabbed-labels>:nth-child(19),.md-typeset .tabbed-set>input:nth-child(2).focus-visible~.tabbed-labels>:nth-child(2),.md-typeset .tabbed-set>input:nth-child(20).focus-visible~.tabbed-labels>:nth-child(20),.md-typeset .tabbed-set>input:nth-child(3).focus-visible~.tabbed-labels>:nth-child(3),.md-typeset .tabbed-set>input:nth-child(4).focus-visible~.tabbed-labels>:nth-child(4),.md-typeset .tabbed-set>input:nth-child(5).focus-visible~.tabbed-labels>:nth-child(5),.md-typeset .tabbed-set>input:nth-child(6).focus-visible~.tabbed-labels>:nth-child(6),.md-typeset .tabbed-set>input:nth-child(7).focus-visible~.tabbed-labels>:nth-child(7),.md-typeset .tabbed-set>input:nth-child(8).focus-visible~.tabbed-labels>:nth-child(8),.md-typeset .tabbed-set>input:nth-child(9).focus-visible~.tabbed-labels>:nth-child(9){color:var(--md-accent-fg-color)}.md-typeset .tabbed-set>input:first-child:checked~.tabbed-content>:first-child,.md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-content>:nth-child(10),.md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-content>:nth-child(11),.md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-content>:nth-child(12),.md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-content>:nth-child(13),.md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-content>:nth-child(14),.md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-content>:nth-child(15),.md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-content>:nth-child(16),.md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-content>:nth-child(17),.md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-content>:nth-child(18),.md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-content>:nth-child(19),.md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-content>:nth-child(2),.md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-content>:nth-child(20),.md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-content>:nth-child(3),.md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-content>:nth-child(4),.md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-content>:nth-child(5),.md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-content>:nth-child(6),.md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-content>:nth-child(7),.md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-content>:nth-child(8),.md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-content>:nth-child(9){display:block}:root{--md-tasklist-icon:url('data:image/svg+xml;charset=utf-8,');--md-tasklist-icon--checked:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .task-list-item{list-style-type:none;position:relative}[dir=ltr] .md-typeset .task-list-item [type=checkbox]{left:-2em}[dir=rtl] .md-typeset .task-list-item [type=checkbox]{right:-2em}.md-typeset .task-list-item [type=checkbox]{position:absolute;top:.45em}.md-typeset .task-list-control [type=checkbox]{opacity:0;z-index:-1}[dir=ltr] .md-typeset .task-list-indicator:before{left:-1.5em}[dir=rtl] .md-typeset .task-list-indicator:before{right:-1.5em}.md-typeset .task-list-indicator:before{background-color:var(--md-default-fg-color--lightest);content:"";height:1.25em;-webkit-mask-image:var(--md-tasklist-icon);mask-image:var(--md-tasklist-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.15em;width:1.25em}.md-typeset [type=checkbox]:checked+.task-list-indicator:before{background-color:#00e676;-webkit-mask-image:var(--md-tasklist-icon--checked);mask-image:var(--md-tasklist-icon--checked)}@media print{.giscus,[id=__comments]{display:none}}:root>*{--md-mermaid-font-family:var(--md-text-font-family),sans-serif;--md-mermaid-edge-color:var(--md-code-fg-color);--md-mermaid-node-bg-color:var(--md-accent-fg-color--transparent);--md-mermaid-node-fg-color:var(--md-accent-fg-color);--md-mermaid-label-bg-color:var(--md-default-bg-color);--md-mermaid-label-fg-color:var(--md-code-fg-color);--md-mermaid-sequence-actor-bg-color:var(--md-mermaid-label-bg-color);--md-mermaid-sequence-actor-fg-color:var(--md-mermaid-label-fg-color);--md-mermaid-sequence-actor-border-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-actor-line-color:var(--md-default-fg-color--lighter);--md-mermaid-sequence-actorman-bg-color:var(--md-mermaid-label-bg-color);--md-mermaid-sequence-actorman-line-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-box-bg-color:var(--md-mermaid-node-bg-color);--md-mermaid-sequence-box-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-label-bg-color:var(--md-mermaid-node-bg-color);--md-mermaid-sequence-label-fg-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-loop-bg-color:var(--md-mermaid-node-bg-color);--md-mermaid-sequence-loop-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-loop-border-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-message-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-message-line-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-note-bg-color:var(--md-mermaid-label-bg-color);--md-mermaid-sequence-note-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-note-border-color:var(--md-mermaid-label-fg-color);--md-mermaid-sequence-number-bg-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-number-fg-color:var(--md-accent-bg-color)}.mermaid{line-height:normal;margin:1em 0}.md-typeset .grid{grid-gap:.4rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr));margin:1em 0}.md-typeset .grid.cards>ol,.md-typeset .grid.cards>ul{display:contents}.md-typeset .grid.cards>ol>li,.md-typeset .grid.cards>ul>li,.md-typeset .grid>.card{border:.05rem solid var(--md-default-fg-color--lightest);border-radius:.1rem;display:block;margin:0;padding:.8rem;transition:border .25s,box-shadow .25s}.md-typeset .grid.cards>ol>li:focus-within,.md-typeset .grid.cards>ol>li:hover,.md-typeset .grid.cards>ul>li:focus-within,.md-typeset .grid.cards>ul>li:hover,.md-typeset .grid>.card:focus-within,.md-typeset .grid>.card:hover{border-color:#0000;box-shadow:var(--md-shadow-z2)}.md-typeset .grid.cards>ol>li>hr,.md-typeset .grid.cards>ul>li>hr,.md-typeset .grid>.card>hr{margin-bottom:1em;margin-top:1em}.md-typeset .grid.cards>ol>li>:first-child,.md-typeset .grid.cards>ul>li>:first-child,.md-typeset .grid>.card>:first-child{margin-top:0}.md-typeset .grid.cards>ol>li>:last-child,.md-typeset .grid.cards>ul>li>:last-child,.md-typeset .grid>.card>:last-child{margin-bottom:0}.md-typeset .grid>*,.md-typeset .grid>.admonition,.md-typeset .grid>.highlight>*,.md-typeset .grid>.highlighttable,.md-typeset .grid>.md-typeset details,.md-typeset .grid>details,.md-typeset .grid>pre{margin-bottom:0;margin-top:0}.md-typeset .grid>.highlight>pre:only-child,.md-typeset .grid>.highlight>pre>code,.md-typeset .grid>.highlighttable,.md-typeset .grid>.highlighttable>tbody,.md-typeset .grid>.highlighttable>tbody>tr,.md-typeset .grid>.highlighttable>tbody>tr>.code,.md-typeset .grid>.highlighttable>tbody>tr>.code>.highlight,.md-typeset .grid>.highlighttable>tbody>tr>.code>.highlight>pre,.md-typeset .grid>.highlighttable>tbody>tr>.code>.highlight>pre>code{height:100%}.md-typeset .grid>.tabbed-set{margin-bottom:0;margin-top:0}@media screen and (min-width:45em){[dir=ltr] .md-typeset .inline{float:left}[dir=rtl] .md-typeset .inline{float:right}[dir=ltr] .md-typeset .inline{margin-right:.8rem}[dir=rtl] .md-typeset .inline{margin-left:.8rem}.md-typeset .inline{margin-bottom:.8rem;margin-top:0;width:11.7rem}[dir=ltr] .md-typeset .inline.end{float:right}[dir=rtl] .md-typeset .inline.end{float:left}[dir=ltr] .md-typeset .inline.end{margin-left:.8rem;margin-right:0}[dir=rtl] .md-typeset .inline.end{margin-left:0;margin-right:.8rem}} \ No newline at end of file diff --git a/docs/site/assets/stylesheets/classic/palette.7dc9a0ad.min.css b/docs/site/assets/stylesheets/classic/palette.7dc9a0ad.min.css new file mode 100644 index 0000000..2d83819 --- /dev/null +++ b/docs/site/assets/stylesheets/classic/palette.7dc9a0ad.min.css @@ -0,0 +1 @@ +@media screen{[data-md-color-scheme=slate]{--md-default-fg-color:hsla(var(--md-hue),15%,90%,0.82);--md-default-fg-color--light:hsla(var(--md-hue),15%,90%,0.56);--md-default-fg-color--lighter:hsla(var(--md-hue),15%,90%,0.32);--md-default-fg-color--lightest:hsla(var(--md-hue),15%,90%,0.12);--md-default-bg-color:hsla(var(--md-hue),15%,14%,1);--md-default-bg-color--light:hsla(var(--md-hue),15%,14%,0.54);--md-default-bg-color--lighter:hsla(var(--md-hue),15%,14%,0.26);--md-default-bg-color--lightest:hsla(var(--md-hue),15%,14%,0.07);--md-code-fg-color:hsla(var(--md-hue),18%,86%,0.82);--md-code-bg-color:hsla(var(--md-hue),15%,18%,1);--md-code-bg-color--light:hsla(var(--md-hue),15%,18%,0.9);--md-code-bg-color--lighter:hsla(var(--md-hue),15%,18%,0.54);--md-code-hl-color:#2977ff;--md-code-hl-color--light:#2977ff1a;--md-code-hl-number-color:#e6695b;--md-code-hl-special-color:#f06090;--md-code-hl-function-color:#c973d9;--md-code-hl-constant-color:#9383e2;--md-code-hl-keyword-color:#6791e0;--md-code-hl-string-color:#2fb170;--md-code-hl-name-color:var(--md-code-fg-color);--md-code-hl-operator-color:var(--md-default-fg-color--light);--md-code-hl-punctuation-color:var(--md-default-fg-color--light);--md-code-hl-comment-color:var(--md-default-fg-color--light);--md-code-hl-generic-color:var(--md-default-fg-color--light);--md-code-hl-variable-color:var(--md-default-fg-color--light);--md-typeset-color:var(--md-default-fg-color);--md-typeset-a-color:var(--md-primary-fg-color);--md-typeset-kbd-color:hsla(var(--md-hue),15%,90%,0.12);--md-typeset-kbd-accent-color:hsla(var(--md-hue),15%,90%,0.2);--md-typeset-kbd-border-color:hsla(var(--md-hue),15%,14%,1);--md-typeset-mark-color:#4287ff4d;--md-typeset-table-color:hsla(var(--md-hue),15%,95%,0.12);--md-typeset-table-color--light:hsla(var(--md-hue),15%,95%,0.035);--md-admonition-fg-color:var(--md-default-fg-color);--md-admonition-bg-color:var(--md-default-bg-color);--md-footer-bg-color:hsla(var(--md-hue),15%,10%,0.87);--md-footer-bg-color--dark:hsla(var(--md-hue),15%,8%,1);--md-shadow-z1:0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a;--md-shadow-z2:0 0.2rem 0.5rem #00000040,0 0 0.05rem #00000040;--md-shadow-z3:0 0.2rem 0.5rem #0006,0 0 0.05rem #00000059;color-scheme:dark}[data-md-color-scheme=slate] img[src$="#gh-light-mode-only"],[data-md-color-scheme=slate] img[src$="#only-light"]{display:none}[data-md-color-scheme=slate]{--color-foreground:255 255 255;--color-background:22 23 26;--color-background-subtle:33 34 38;--color-backdrop:11 12 15}[data-md-color-scheme=slate][data-md-color-primary=pink]{--md-typeset-a-color:#ed5487}[data-md-color-scheme=slate][data-md-color-primary=purple]{--md-typeset-a-color:#c46fd3}[data-md-color-scheme=slate][data-md-color-primary=deep-purple]{--md-typeset-a-color:#a47bea}[data-md-color-scheme=slate][data-md-color-primary=indigo]{--md-typeset-a-color:#5488e8}[data-md-color-scheme=slate][data-md-color-primary=teal]{--md-typeset-a-color:#00ccb8}[data-md-color-scheme=slate][data-md-color-primary=green]{--md-typeset-a-color:#71c174}[data-md-color-scheme=slate][data-md-color-primary=deep-orange]{--md-typeset-a-color:#ff764d}[data-md-color-scheme=slate][data-md-color-primary=brown]{--md-typeset-a-color:#c1775c}[data-md-color-scheme=slate][data-md-color-primary=black],[data-md-color-scheme=slate][data-md-color-primary=blue-grey],[data-md-color-scheme=slate][data-md-color-primary=grey],[data-md-color-scheme=slate][data-md-color-primary=white]{--md-typeset-a-color:#5e8bde}[data-md-color-switching] *,[data-md-color-switching] :after,[data-md-color-switching] :before{transition-duration:0ms!important}}[data-md-color-accent=red]{--md-accent-fg-color:#ff1947;--md-accent-fg-color--transparent:#ff19471a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=pink]{--md-accent-fg-color:#f50056;--md-accent-fg-color--transparent:#f500561a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=purple]{--md-accent-fg-color:#df41fb;--md-accent-fg-color--transparent:#df41fb1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=deep-purple]{--md-accent-fg-color:#7c4dff;--md-accent-fg-color--transparent:#7c4dff1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=indigo]{--md-accent-fg-color:#526cfe;--md-accent-fg-color--transparent:#526cfe1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=blue]{--md-accent-fg-color:#4287ff;--md-accent-fg-color--transparent:#4287ff1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=light-blue]{--md-accent-fg-color:#0091eb;--md-accent-fg-color--transparent:#0091eb1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=cyan]{--md-accent-fg-color:#00bad6;--md-accent-fg-color--transparent:#00bad61a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=teal]{--md-accent-fg-color:#00bda4;--md-accent-fg-color--transparent:#00bda41a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=green]{--md-accent-fg-color:#00c753;--md-accent-fg-color--transparent:#00c7531a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=light-green]{--md-accent-fg-color:#63de17;--md-accent-fg-color--transparent:#63de171a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=lime]{--md-accent-fg-color:#b0eb00;--md-accent-fg-color--transparent:#b0eb001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=yellow]{--md-accent-fg-color:#ffd500;--md-accent-fg-color--transparent:#ffd5001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=amber]{--md-accent-fg-color:#fa0;--md-accent-fg-color--transparent:#ffaa001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=orange]{--md-accent-fg-color:#ff9100;--md-accent-fg-color--transparent:#ff91001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=deep-orange]{--md-accent-fg-color:#ff6e42;--md-accent-fg-color--transparent:#ff6e421a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-primary=red]{--md-primary-fg-color:#ef5552;--md-primary-fg-color--light:#e57171;--md-primary-fg-color--dark:#e53734;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=pink]{--md-primary-fg-color:#e92063;--md-primary-fg-color--light:#ec417a;--md-primary-fg-color--dark:#c3185d;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=purple]{--md-primary-fg-color:#ab47bd;--md-primary-fg-color--light:#bb69c9;--md-primary-fg-color--dark:#8c24a8;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=deep-purple]{--md-primary-fg-color:#7e56c2;--md-primary-fg-color--light:#9574cd;--md-primary-fg-color--dark:#673ab6;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=indigo]{--md-primary-fg-color:#4051b5;--md-primary-fg-color--light:#5d6cc0;--md-primary-fg-color--dark:#303fa1;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=blue]{--md-primary-fg-color:#2094f3;--md-primary-fg-color--light:#42a5f5;--md-primary-fg-color--dark:#1975d2;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=light-blue]{--md-primary-fg-color:#02a6f2;--md-primary-fg-color--light:#28b5f6;--md-primary-fg-color--dark:#0287cf;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=cyan]{--md-primary-fg-color:#00bdd6;--md-primary-fg-color--light:#25c5da;--md-primary-fg-color--dark:#0097a8;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=teal]{--md-primary-fg-color:#009485;--md-primary-fg-color--light:#26a699;--md-primary-fg-color--dark:#007a6c;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=green]{--md-primary-fg-color:#4cae4f;--md-primary-fg-color--light:#68bb6c;--md-primary-fg-color--dark:#398e3d;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=light-green]{--md-primary-fg-color:#8bc34b;--md-primary-fg-color--light:#9ccc66;--md-primary-fg-color--dark:#689f38;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=lime]{--md-primary-fg-color:#cbdc38;--md-primary-fg-color--light:#d3e156;--md-primary-fg-color--dark:#b0b52c;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=yellow]{--md-primary-fg-color:#ffec3d;--md-primary-fg-color--light:#ffee57;--md-primary-fg-color--dark:#fbc02d;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=amber]{--md-primary-fg-color:#ffc105;--md-primary-fg-color--light:#ffc929;--md-primary-fg-color--dark:#ffa200;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=orange]{--md-primary-fg-color:#ffa724;--md-primary-fg-color--light:#ffa724;--md-primary-fg-color--dark:#fa8900;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=deep-orange]{--md-primary-fg-color:#ff6e42;--md-primary-fg-color--light:#ff8a66;--md-primary-fg-color--dark:#f4511f;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=brown]{--md-primary-fg-color:#795649;--md-primary-fg-color--light:#8d6e62;--md-primary-fg-color--dark:#5d4037;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=grey]{--md-primary-fg-color:#757575;--md-primary-fg-color--light:#9e9e9e;--md-primary-fg-color--dark:#616161;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3;--md-typeset-a-color:#4051b5}[data-md-color-primary=blue-grey]{--md-primary-fg-color:#546d78;--md-primary-fg-color--light:#607c8a;--md-primary-fg-color--dark:#455a63;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3;--md-typeset-a-color:#4051b5}[data-md-color-primary=light-green]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#72ad2e}[data-md-color-primary=lime]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#8b990a}[data-md-color-primary=yellow]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#b8a500}[data-md-color-primary=amber]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#d19d00}[data-md-color-primary=orange]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#e68a00}[data-md-color-primary=white]{--md-primary-fg-color:hsla(var(--md-hue),0%,100%,1);--md-primary-fg-color--light:hsla(var(--md-hue),0%,100%,0.7);--md-primary-fg-color--dark:hsla(var(--md-hue),0%,0%,0.07);--md-primary-bg-color:hsla(var(--md-hue),0%,0%,0.87);--md-primary-bg-color--light:hsla(var(--md-hue),0%,0%,0.54);--md-typeset-a-color:#4051b5}[data-md-color-primary=white] .md-button{color:var(--md-typeset-a-color)}[data-md-color-primary=white] .md-button--primary{background-color:var(--md-typeset-a-color);border-color:var(--md-typeset-a-color);color:hsla(var(--md-hue),0%,100%,1)}@media screen and (min-width:60em){[data-md-color-primary=white] .md-search__form{background-color:hsla(var(--md-hue),0%,0%,.07)}[data-md-color-primary=white] .md-search__form:hover{background-color:hsla(var(--md-hue),0%,0%,.32)}[data-md-color-primary=white] .md-search__input+.md-search__icon{color:hsla(var(--md-hue),0%,0%,.87)}}@media screen and (min-width:76.25em){[data-md-color-primary=white] .md-tabs{border-bottom:.05rem solid #00000012}}[data-md-color-primary=black]{--md-primary-fg-color:hsla(var(--md-hue),15%,9%,1);--md-primary-fg-color--light:hsla(var(--md-hue),15%,9%,0.54);--md-primary-fg-color--dark:hsla(var(--md-hue),15%,9%,1);--md-primary-bg-color:hsla(var(--md-hue),15%,100%,1);--md-primary-bg-color--light:hsla(var(--md-hue),15%,100%,0.7);--md-typeset-a-color:#4051b5}[data-md-color-primary=black] .md-button{color:var(--md-typeset-a-color)}[data-md-color-primary=black] .md-button--primary{background-color:var(--md-typeset-a-color);border-color:var(--md-typeset-a-color);color:hsla(var(--md-hue),0%,100%,1)}[data-md-color-primary=black] .md-header{background-color:hsla(var(--md-hue),15%,9%,1)}@media screen and (max-width:59.984375em){[data-md-color-primary=black] .md-nav__source{background-color:hsla(var(--md-hue),15%,11%,.87)}}@media screen and (max-width:76.234375em){html [data-md-color-primary=black] .md-nav--primary .md-nav__title[for=__drawer]{background-color:hsla(var(--md-hue),15%,9%,1)}}@media screen and (min-width:76.25em){[data-md-color-primary=black] .md-tabs{background-color:hsla(var(--md-hue),15%,9%,1)}} \ No newline at end of file diff --git a/docs/site/assets/stylesheets/modern/main.53a7feaf.min.css b/docs/site/assets/stylesheets/modern/main.53a7feaf.min.css new file mode 100644 index 0000000..04e34a2 --- /dev/null +++ b/docs/site/assets/stylesheets/modern/main.53a7feaf.min.css @@ -0,0 +1 @@ +@charset "UTF-8";html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;box-sizing:border-box}*,:after,:before{box-sizing:inherit}@media (prefers-reduced-motion){*,:after,:before{transition:none!important}}body{margin:0}a,button,input,label{-webkit-tap-highlight-color:transparent}a{color:inherit;text-decoration:none}hr{border:0;box-sizing:initial;display:block;height:.05rem;overflow:visible;padding:0}small{font-size:80%}sub,sup{line-height:1em}img{border-style:none}table{border-collapse:initial;border-spacing:0}td,th{font-weight:400;vertical-align:top}button{background:#0000;border:0;font-family:inherit;font-size:inherit;margin:0;padding:0}input{border:0;outline:none}:root{--md-primary-fg-color:#4051b5;--md-primary-fg-color--light:#5d6cc0;--md-primary-fg-color--dark:#303fa1;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3;--md-accent-fg-color:#526cfe;--md-accent-fg-color--transparent:#526cfe1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-scheme=default]{color-scheme:light}[data-md-color-scheme=default] img[src$="#gh-dark-mode-only"],[data-md-color-scheme=default] img[src$="#only-dark"]{display:none}:root,[data-md-color-scheme=default]{--md-hue:225deg;--md-default-fg-color:#000000de;--md-default-fg-color--light:#0000008c;--md-default-fg-color--lighter:#00000052;--md-default-fg-color--lightest:#0000000d;--md-default-bg-color:#fff;--md-default-bg-color--light:#ffffffb3;--md-default-bg-color--lighter:#ffffff4d;--md-default-bg-color--lightest:#ffffff1f;--md-code-fg-color:#36464e;--md-code-bg-color:#f5f5f5;--md-code-bg-color--light:#f5f5f5b3;--md-code-bg-color--lighter:#f5f5f54d;--md-code-hl-color:#4287ff;--md-code-hl-color--light:#4287ff1a;--md-code-hl-number-color:#d52a2a;--md-code-hl-special-color:#db1457;--md-code-hl-function-color:#a846b9;--md-code-hl-constant-color:#6e59d9;--md-code-hl-keyword-color:#3f6ec6;--md-code-hl-string-color:#1c7d4d;--md-code-hl-name-color:var(--md-code-fg-color);--md-code-hl-operator-color:var(--md-default-fg-color--light);--md-code-hl-punctuation-color:var(--md-default-fg-color--light);--md-code-hl-comment-color:var(--md-default-fg-color--light);--md-code-hl-generic-color:var(--md-default-fg-color--light);--md-code-hl-variable-color:var(--md-default-fg-color--light);--md-typeset-color:var(--md-default-fg-color);--md-typeset-a-color:var(--md-primary-fg-color);--md-typeset-del-color:#f5503d26;--md-typeset-ins-color:#0bd57026;--md-typeset-kbd-color:#fafafa;--md-typeset-kbd-accent-color:#fff;--md-typeset-kbd-border-color:#b8b8b8;--md-typeset-mark-color:#ffff0080;--md-typeset-table-color:#0000001f;--md-typeset-table-color--light:rgba(0,0,0,.035);--md-admonition-fg-color:var(--md-default-fg-color);--md-admonition-bg-color:var(--md-default-bg-color);--md-warning-fg-color:#000000de;--md-warning-bg-color:#ff9;--md-shadow-z1:0 0.2rem 0.5rem #0000000d,0 0 0.05rem #0000001a;--md-shadow-z2:0 0.2rem 0.5rem #0000001a,0 0 0.05rem #00000040;--md-shadow-z3:0 0.2rem 0.5rem #0003,0 0 0.05rem #00000059;--color-foreground:0 0 0;--color-background:255 255 255;--color-background-subtle:240 240 240;--color-backdrop:255 255 255}.md-icon svg{fill:currentcolor;display:block;height:1.2rem;width:1.2rem}.md-icon svg.lucide{fill:#0000;stroke:currentcolor}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--md-text-font-family:var(--md-text-font,_),-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;--md-code-font-family:var(--md-code-font,_),SFMono-Regular,Consolas,Menlo,monospace}aside,body,input{font-feature-settings:"kern","liga";color:var(--md-typeset-color);font-family:var(--md-text-font-family)}code,kbd,pre{font-feature-settings:"kern";font-family:var(--md-code-font-family)}:root{--md-typeset-table-sort-icon:url('data:image/svg+xml;charset=utf-8,');--md-typeset-table-sort-icon--asc:url('data:image/svg+xml;charset=utf-8,');--md-typeset-table-sort-icon--desc:url('data:image/svg+xml;charset=utf-8,');--md-typeset-preview-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset{-webkit-print-color-adjust:exact;color-adjust:exact;font-size:.75rem;letter-spacing:-.01em;line-height:1.8;overflow-wrap:break-word}@media print{.md-typeset{font-size:.68rem}}.md-typeset blockquote,.md-typeset dl,.md-typeset figure,.md-typeset ol,.md-typeset pre,.md-typeset ul{margin-bottom:1em;margin-top:1em}.md-typeset h1{color:var(--md-default-fg-color);font-size:1.875em;line-height:1.3;margin:0 0 1.25em}.md-typeset h1,.md-typeset h2{font-weight:700;letter-spacing:-.025em}.md-typeset h2{font-size:1.5em;line-height:1.4;margin:1.6em 0 .64em}.md-typeset h3{font-size:1.25em;font-weight:700;letter-spacing:-.01em;line-height:1.5;margin:1.6em 0 .8em}.md-typeset h2+h3{margin-top:.8em}.md-typeset h4{font-weight:700;letter-spacing:-.01em;margin:1em 0}.md-typeset h5,.md-typeset h6{color:var(--md-default-fg-color--light);font-size:.8em;font-weight:700;letter-spacing:-.01em;margin:1.25em 0}.md-typeset h5{text-transform:uppercase}.md-typeset h5 code{text-transform:none}.md-typeset hr{border-bottom:.05rem solid var(--md-default-fg-color--lightest);display:flow-root;margin:1.5em 0}.md-typeset a{color:var(--md-typeset-a-color);text-decoration:underline;word-break:break-word}.md-typeset a,.md-typeset a:before{transition:color 125ms}.md-typeset a:focus,.md-typeset a:hover{color:var(--md-accent-fg-color)}.md-typeset a:focus code,.md-typeset a:hover code{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}.md-typeset a code{color:var(--md-typeset-a-color)}.md-typeset a.focus-visible{outline-color:var(--md-accent-fg-color);outline-offset:.2rem}.md-typeset code,.md-typeset kbd,.md-typeset pre{color:var(--md-code-fg-color);direction:ltr;font-variant-ligatures:none;transition:background-color 125ms}@media print{.md-typeset code,.md-typeset kbd,.md-typeset pre{white-space:pre-wrap}}.md-typeset code{background-color:var(--md-code-bg-color);border-radius:.2rem;-webkit-box-decoration-break:clone;box-decoration-break:clone;font-size:.85em;padding:.25em .4em;transition:color 125ms,background-color 125ms;word-break:break-word}.md-typeset code:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}.md-typeset pre{display:flow-root;line-height:1.4;position:relative}.md-typeset pre>code{border-radius:.4rem;-webkit-box-decoration-break:slice;box-decoration-break:slice;box-shadow:none;display:block;margin:0;outline-color:var(--md-accent-fg-color);overflow:auto;padding:.7720588235em 1.1764705882em;scrollbar-color:var(--md-default-fg-color--lighter) #0000;scrollbar-width:thin;touch-action:auto;word-break:normal}.md-typeset pre>code:hover{scrollbar-color:var(--md-accent-fg-color) #0000}.md-typeset pre>code::-webkit-scrollbar{height:.2rem;width:.2rem}.md-typeset pre>code::-webkit-scrollbar-thumb{background-color:var(--md-default-fg-color--lighter)}.md-typeset pre>code::-webkit-scrollbar-thumb:hover{background-color:var(--md-accent-fg-color)}.md-typeset kbd{border-radius:.2rem;box-shadow:0 0 0 .05rem var(--md-typeset-kbd-border-color),0 .15rem 0 var(--md-typeset-kbd-border-color);color:var(--md-default-fg-color);display:inline-block;font-size:.75em;padding:0 .6666666667em;vertical-align:text-top;word-break:break-word}.md-typeset mark{background-color:var(--md-typeset-mark-color);-webkit-box-decoration-break:clone;box-decoration-break:clone;color:inherit;word-break:break-word}.md-typeset abbr{border-bottom:.05rem dotted var(--md-default-fg-color--light);cursor:help;text-decoration:none}.md-typeset [data-preview]{position:relative}[dir=ltr] .md-typeset [data-preview]:after{margin-left:.125em}[dir=rtl] .md-typeset [data-preview]:after{margin-right:.125em}.md-typeset [data-preview]:after{background-color:currentcolor;content:"";display:inline-block;height:.8em;-webkit-mask-image:var(--md-typeset-preview-icon);mask-image:var(--md-typeset-preview-icon);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:background-color 125ms;vertical-align:text-top;width:.8em}.md-typeset small{opacity:.75}[dir=ltr] .md-typeset sub,[dir=ltr] .md-typeset sup{margin-left:.078125em}[dir=rtl] .md-typeset sub,[dir=rtl] .md-typeset sup{margin-right:.078125em}[dir=ltr] .md-typeset blockquote{padding-left:.6rem}[dir=rtl] .md-typeset blockquote{padding-right:.6rem}[dir=ltr] .md-typeset blockquote{border-left:.2rem solid var(--md-default-fg-color--lighter)}[dir=rtl] .md-typeset blockquote{border-right:.2rem solid var(--md-default-fg-color--lighter)}.md-typeset blockquote{color:var(--md-default-fg-color--light);margin-left:0;margin-right:0}.md-typeset ul{list-style-type:disc}.md-typeset ul[type]{list-style-type:revert-layer}[dir=ltr] .md-typeset ol,[dir=ltr] .md-typeset ul{margin-left:.625em}[dir=rtl] .md-typeset ol,[dir=rtl] .md-typeset ul{margin-right:.625em}.md-typeset ol,.md-typeset ul{padding:0}.md-typeset ol:not([hidden]),.md-typeset ul:not([hidden]){display:flow-root}.md-typeset ol ol,.md-typeset ul ol{list-style-type:lower-alpha}.md-typeset ol ol ol,.md-typeset ul ol ol{list-style-type:lower-roman}.md-typeset ol ol ol ol,.md-typeset ul ol ol ol{list-style-type:upper-alpha}.md-typeset ol ol ol ol ol,.md-typeset ul ol ol ol ol{list-style-type:upper-roman}.md-typeset ol[type],.md-typeset ul[type]{list-style-type:revert-layer}[dir=ltr] .md-typeset ol li,[dir=ltr] .md-typeset ul li{margin-left:1.25em}[dir=rtl] .md-typeset ol li,[dir=rtl] .md-typeset ul li{margin-right:1.25em}.md-typeset ol li,.md-typeset ul li{margin-bottom:.5em}.md-typeset ol li blockquote,.md-typeset ol li p,.md-typeset ul li blockquote,.md-typeset ul li p{margin:.5em 0}.md-typeset ol li:last-child,.md-typeset ul li:last-child{margin-bottom:0}[dir=ltr] .md-typeset ol li ol,[dir=ltr] .md-typeset ol li ul,[dir=ltr] .md-typeset ul li ol,[dir=ltr] .md-typeset ul li ul{margin-left:.625em}[dir=rtl] .md-typeset ol li ol,[dir=rtl] .md-typeset ol li ul,[dir=rtl] .md-typeset ul li ol,[dir=rtl] .md-typeset ul li ul{margin-right:.625em}.md-typeset ol li ol,.md-typeset ol li ul,.md-typeset ul li ol,.md-typeset ul li ul{margin-bottom:.5em;margin-top:.5em}[dir=ltr] .md-typeset dd{margin-left:1.875em}[dir=rtl] .md-typeset dd{margin-right:1.875em}.md-typeset dd{margin-bottom:1.5em;margin-top:1em}.md-typeset img,.md-typeset svg,.md-typeset video{height:auto;max-width:100%}.md-typeset img[align=left]{margin:1em 1em 1em 0}.md-typeset img[align=right]{margin:1em 0 1em 1em}.md-typeset img[align]:only-child{margin-top:0}.md-typeset figure{display:flow-root;margin:1em auto;max-width:100%;text-align:center;width:fit-content}.md-typeset figure img{display:block;margin:0 auto}.md-typeset figcaption{font-style:italic;margin:1em auto;max-width:24rem}.md-typeset iframe{max-width:100%}.md-typeset table:not([class]){background-color:var(--md-default-bg-color);border:.05rem solid var(--md-typeset-table-color);border-radius:.1rem;display:inline-block;font-size:.64rem;max-width:100%;overflow:auto;touch-action:auto}@media print{.md-typeset table:not([class]){display:table}}.md-typeset table:not([class])+*{margin-top:1.5em}.md-typeset table:not([class]) td>:first-child,.md-typeset table:not([class]) th>:first-child{margin-top:0}.md-typeset table:not([class]) td>:last-child,.md-typeset table:not([class]) th>:last-child{margin-bottom:0}.md-typeset table:not([class]) td:not([align]),.md-typeset table:not([class]) th:not([align]){text-align:left}[dir=rtl] .md-typeset table:not([class]) td:not([align]),[dir=rtl] .md-typeset table:not([class]) th:not([align]){text-align:right}.md-typeset table:not([class]) th{font-weight:700;min-width:5rem;padding:.9375em 1.25em;vertical-align:top}.md-typeset table:not([class]) td{border-top:.05rem solid var(--md-typeset-table-color);padding:.9375em 1.25em;vertical-align:top}.md-typeset table:not([class]) tbody tr{transition:background-color 125ms}.md-typeset table:not([class]) tbody tr:hover{background-color:var(--md-typeset-table-color--light);box-shadow:0 .05rem 0 var(--md-default-bg-color) inset}.md-typeset table:not([class]) a{word-break:normal}.md-typeset table th[role=columnheader]{cursor:pointer}[dir=ltr] .md-typeset table th[role=columnheader]:after{margin-left:.5em}[dir=rtl] .md-typeset table th[role=columnheader]:after{margin-right:.5em}.md-typeset table th[role=columnheader]:after{content:"";display:inline-block;height:1.2em;-webkit-mask-image:var(--md-typeset-table-sort-icon);mask-image:var(--md-typeset-table-sort-icon);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:background-color 125ms;vertical-align:text-bottom;width:1.2em}.md-typeset table th[role=columnheader]:hover:after{background-color:var(--md-default-fg-color--lighter)}.md-typeset table th[role=columnheader][aria-sort=ascending]:after{background-color:var(--md-default-fg-color--light);-webkit-mask-image:var(--md-typeset-table-sort-icon--asc);mask-image:var(--md-typeset-table-sort-icon--asc)}.md-typeset table th[role=columnheader][aria-sort=descending]:after{background-color:var(--md-default-fg-color--light);-webkit-mask-image:var(--md-typeset-table-sort-icon--desc);mask-image:var(--md-typeset-table-sort-icon--desc)}.md-typeset__scrollwrap{margin:1em -.8rem;overflow-x:auto;touch-action:auto}.md-typeset__table{display:inline-block;margin-bottom:.5em;padding:0 .8rem}@media print{.md-typeset__table{display:block}}html .md-typeset__table table{display:table;margin:0;overflow:hidden;width:100%}@media screen and (max-width:44.984375em){.md-content__inner>pre{margin:1em -.8rem}.md-content__inner>pre code{border-radius:0}}.md-banner{background-color:var(--md-accent-fg-color--transparent);color:var(--md-default-fg-color);overflow:auto}@media print{.md-banner{display:none}}.md-banner--warning{background-color:var(--md-warning-bg-color);color:var(--md-warning-fg-color)}.md-banner__inner{font-size:.7rem;margin:.6rem auto;padding:0 .8rem}[dir=ltr] .md-banner__button{float:right}[dir=rtl] .md-banner__button{float:left}.md-banner__button{color:inherit;cursor:pointer;transition:opacity .25s}.no-js .md-banner__button{display:none}.md-banner__button:hover{opacity:.7}html{scrollbar-gutter:stable;font-size:125%;height:100%;overflow-x:hidden}@media screen and (min-width:100em){html{font-size:137.5%}}@media screen and (min-width:125em){html{font-size:150%}}body{background-color:var(--md-default-bg-color);display:flex;flex-direction:column;font-size:.5rem;min-height:100%;position:relative;width:100%}@media print{body{display:block}}@media screen and (max-width:59.984375em){body[data-md-scrolllock]{position:fixed}}.md-grid{margin-left:auto;margin-right:auto;max-width:61rem}.md-container{display:flex;flex-direction:column;flex-grow:1}@media print{.md-container{display:block}}.md-main{flex-grow:1}.md-main__inner{display:flex;height:100%;margin-top:1.5rem}.md-ellipsis{overflow:hidden;text-overflow:ellipsis}.md-toggle{display:none}.md-option{height:0;opacity:0;position:absolute;width:0}.md-option:checked+label:not([hidden]){display:block}.md-option.focus-visible+label{outline-color:var(--md-accent-fg-color);outline-style:auto}.md-skip{background-color:var(--md-default-fg-color);border-radius:.1rem;color:var(--md-default-bg-color);font-size:.64rem;margin:.5rem;opacity:0;outline-color:var(--md-accent-fg-color);padding:.3rem .5rem;position:fixed;transform:translateY(.4rem);z-index:-1}.md-skip:focus{opacity:1;transform:translateY(0);transition:transform .25s cubic-bezier(.4,0,.2,1),opacity 175ms 75ms;z-index:10}@page{margin:25mm}:root{--md-clipboard-icon:url('data:image/svg+xml;charset=utf-8,')}.md-clipboard{border-radius:.1rem;color:var(--md-default-fg-color--lightest);cursor:pointer;height:1.5em;outline-color:var(--md-accent-fg-color);outline-offset:.1rem;transition:color .25s;width:1.5em;z-index:1}@media print{.md-clipboard{display:none}}.md-clipboard:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}:hover>.md-clipboard{color:var(--md-default-fg-color--light)}.md-clipboard:focus,.md-clipboard:hover{color:var(--md-accent-fg-color)}.md-clipboard:after{background-color:currentcolor;content:"";display:block;height:1.125em;margin:0 auto;-webkit-mask-image:var(--md-clipboard-icon);mask-image:var(--md-clipboard-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:1.125em}.md-clipboard--inline{cursor:pointer}.md-clipboard--inline code{transition:color .25s,background-color .25s}.md-clipboard--inline:focus code,.md-clipboard--inline:hover code{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}:root{--md-code-select-icon:url('data:image/svg+xml;charset=utf-8,');--md-code-copy-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .md-code__content{display:grid}.md-code__nav{background-color:var(--md-code-bg-color--lighter);border-radius:.1rem;display:flex;gap:.2rem;padding:.2rem;position:absolute;right:.25em;top:.25em;transition:background-color .25s;z-index:1}:hover>.md-code__nav{background-color:var(--md-code-bg-color--light)}.md-code__button{color:var(--md-default-fg-color--lightest);cursor:pointer;display:block;height:1.5em;outline-color:var(--md-accent-fg-color);outline-offset:.1rem;transition:color .25s;width:1.5em}:hover>*>.md-code__button{color:var(--md-default-fg-color--light)}.md-code__button.focus-visible,.md-code__button:hover{color:var(--md-accent-fg-color)}.md-code__button--active{color:var(--md-default-fg-color)!important}.md-code__button:after{background-color:currentcolor;content:"";display:block;height:1.125em;margin:0 auto;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:1.125em}.md-code__button[data-md-type=select]:after{-webkit-mask-image:var(--md-code-select-icon);mask-image:var(--md-code-select-icon)}.md-code__button[data-md-type=copy]:after{-webkit-mask-image:var(--md-code-copy-icon);mask-image:var(--md-code-copy-icon)}@keyframes consent{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:translateY(0)}}@keyframes overlay{0%{opacity:0}to{opacity:1}}.md-consent__overlay{animation:overlay .35s both;-webkit-backdrop-filter:blur(.2rem);backdrop-filter:blur(.2rem);background-color:var(--md-default-bg-color--light);height:100%;opacity:1;position:fixed;top:0;width:100%;z-index:5}.md-consent__inner{bottom:0;display:flex;justify-content:center;max-height:100%;padding:0;position:fixed;width:100%;z-index:5}.md-consent__form{animation:consent .5s cubic-bezier(.1,.7,.1,1) both;background-color:var(--md-default-bg-color);border:0;border-radius:.8rem;box-shadow:var(--md-shadow-z3);margin:.4rem;overflow:auto;padding-left:1.2rem;padding-right:1.2rem}.md-consent__settings{display:none;margin:1em 0}input:checked+.md-consent__settings{display:block}.md-consent__controls{line-height:1.2;margin-bottom:.8rem}.md-typeset .md-consent__controls .md-button{display:inline}@media screen and (max-width:44.984375em){.md-typeset .md-consent__controls .md-button{display:block;margin-top:.4rem;text-align:center;width:100%}}.md-consent label{cursor:pointer}.md-content{flex-grow:1;min-width:0}.md-content__inner{margin:0 .8rem 1.2rem;padding-top:.7rem}@media screen and (min-width:76.25em){[dir=ltr] .md-sidebar--primary:not([hidden])~.md-content>.md-content__inner{margin-left:1.2rem}[dir=ltr] .md-sidebar--secondary:not([hidden])~.md-content>.md-content__inner,[dir=rtl] .md-sidebar--primary:not([hidden])~.md-content>.md-content__inner{margin-right:1.2rem}[dir=rtl] .md-sidebar--secondary:not([hidden])~.md-content>.md-content__inner{margin-left:1.2rem}}.md-content__inner:before{content:"";display:block;height:.4rem}.md-content__inner>:last-child{margin-bottom:0}[dir=ltr] .md-content__button{float:right}[dir=rtl] .md-content__button{float:left}[dir=ltr] .md-content__button{margin-left:.4rem}[dir=rtl] .md-content__button{margin-right:.4rem}.md-content__button{background-color:var(--md-default-fg-color--lightest);border-radius:.4rem;display:flex;margin-top:.2rem;padding:.3rem}@media print{.md-content__button{display:none}}.md-typeset .md-content__button{color:var(--md-default-fg-color);transition:color .25s,background-color .25s}.md-typeset .md-content__button svg{opacity:.5;transition:opacity .25s}.md-typeset .md-content__button:focus,.md-typeset .md-content__button:hover{background-color:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}.md-typeset .md-content__button:focus svg,.md-typeset .md-content__button:hover svg{opacity:1}.md-content__button svg{height:.9rem;width:.9rem}[dir=rtl] .md-content__button svg{transform:scaleX(-1)}.md-content__button svg.lucide{fill:#0000;stroke:currentcolor}[dir=ltr] .md-dialog{right:.8rem}[dir=rtl] .md-dialog{left:.8rem}.md-dialog{background-color:var(--md-accent-fg-color);border-radius:1.2rem;bottom:.8rem;box-shadow:var(--md-shadow-z3);min-width:11.1rem;opacity:0;padding:.4rem 1.2rem;pointer-events:none;position:fixed;transform:translateY(100%);transition:transform 0ms .4s,opacity .4s;z-index:4}@media print{.md-dialog{display:none}}.md-dialog--active{opacity:1;pointer-events:auto;transform:translateY(0);transition:transform .4s cubic-bezier(.075,.85,.175,1),opacity .4s}.md-dialog__inner{color:var(--md-default-bg-color);font-size:.7rem}.md-feedback{margin:2em 0 1em;text-align:center}.md-feedback fieldset{border:none;margin:0;padding:0}.md-feedback__title{font-weight:700;margin:1em auto}.md-feedback__inner{position:relative}.md-feedback__list{display:flex;flex-wrap:wrap;place-content:baseline center;position:relative}.md-feedback__list:hover .md-icon:not(:disabled){color:var(--md-default-fg-color--lighter)}:disabled .md-feedback__list{min-height:1.8rem}.md-feedback__icon{color:var(--md-default-fg-color--light);cursor:pointer;flex-shrink:0;margin:0 .1rem;transition:color 125ms}.md-feedback__icon:not(:disabled).md-icon:hover{color:var(--md-accent-fg-color)}.md-feedback__icon:disabled{color:var(--md-default-fg-color--lightest);pointer-events:none}.md-feedback__note{opacity:0;position:relative;transform:translateY(.4rem);transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .15s}.md-feedback__note>*{margin:0 auto;max-width:16rem}:disabled .md-feedback__note{opacity:1;transform:translateY(0)}@media print{.md-feedback{display:none}}.md-footer{background-color:var(--md-default-bg-color);border-top:.05rem solid var(--md-default-fg-color--lightest);color:var(--md-default-fg-color)}@media print{.md-footer{display:none}}.md-footer__inner{justify-content:space-between;overflow:auto;padding:.2rem}.md-footer__inner:not([hidden]){display:flex}.md-footer__link{align-items:end;display:flex;flex-grow:0.01;margin-bottom:.4rem;margin-top:1rem;max-width:100%;outline-color:var(--md-accent-fg-color);overflow:hidden;transition:opacity .25s}.md-footer__link:focus,.md-footer__link:hover{opacity:.7}[dir=rtl] .md-footer__link svg{transform:scaleX(-1)}@media screen and (max-width:44.984375em){.md-footer__link--prev{flex-shrink:0}.md-footer__link--prev .md-footer__title{display:none}}[dir=ltr] .md-footer__link--next{margin-left:auto}[dir=rtl] .md-footer__link--next{margin-right:auto}.md-footer__link--next{text-align:right}[dir=rtl] .md-footer__link--next{text-align:left}.md-footer__title{flex-grow:1;font-size:.8rem;margin-bottom:.7rem;max-width:calc(100% - 2.4rem);padding:0 1rem;white-space:nowrap}.md-footer__button{margin:.2rem;padding:.4rem}.md-footer__direction{font-size:.6rem;opacity:.7}.md-footer-meta{background-color:var(--md-default-fg-color--lightest)}.md-footer-meta__inner{display:flex;flex-wrap:wrap;justify-content:space-between;padding:.2rem}html .md-footer-meta.md-typeset a:not(:focus,:hover){color:var(--md-default-fg-color)}.md-copyright{color:var(--md-default-fg-color--light);font-size:.64rem;margin:auto .6rem;padding:.4rem 0;width:100%}@media screen and (min-width:45em){.md-copyright{width:auto}}.md-copyright__highlight{color:var(--md-default-fg-color)}.md-social{display:inline-flex;gap:.2rem;margin:0 .4rem;padding:.2rem 0 .6rem}@media screen and (min-width:45em){.md-social{padding:.6rem 0}}.md-social__link{display:inline-block;height:1.6rem;text-align:center;width:1.6rem}.md-social__link:before{line-height:1.9}.md-social__link svg{fill:currentcolor;max-height:.8rem;vertical-align:-25%}.md-social__link svg.lucide{fill:#0000;stroke:currentcolor}.md-typeset .md-button{background-color:var(--md-default-fg-color--lightest);border-radius:1.2rem;color:var(--md-default-fg-color--light);cursor:pointer;display:inline-block;font-size:.875em;font-weight:700;padding:.625em 2em;text-decoration:none;transition:color 125ms,background-color 125ms,opacity 125ms}.md-typeset .md-button.focus-visible{outline-offset:0}.md-typeset .md-button:focus,.md-typeset .md-button:hover{color:var(--md-default-fg-color--light);opacity:.8}.md-typeset .md-button--primary{background-color:var(--md-primary-fg-color);color:var(--md-primary-bg-color)}.md-typeset .md-button--primary:focus,.md-typeset .md-button--primary:hover{color:var(--md-primary-bg-color);opacity:.8}[dir=ltr] .md-typeset .md-input{border-top-left-radius:.1rem}[dir=ltr] .md-typeset .md-input,[dir=rtl] .md-typeset .md-input{border-top-right-radius:.1rem}[dir=rtl] .md-typeset .md-input{border-top-left-radius:.1rem}.md-typeset .md-input{border-bottom:.1rem solid var(--md-default-fg-color--lighter);box-shadow:var(--md-shadow-z1);font-size:.8rem;height:1.8rem;padding:0 .6rem;transition:border .25s,box-shadow .25s}.md-typeset .md-input:focus,.md-typeset .md-input:hover{border-bottom-color:var(--md-accent-fg-color);box-shadow:var(--md-shadow-z2)}.md-typeset .md-input--stretch{width:100%}.md-header{-webkit-backdrop-filter:blur(.4rem);backdrop-filter:blur(.4rem);background-color:var(--md-default-bg-color--light);color:var(--md-default-fg-color);display:block;left:0;position:sticky;right:0;top:0;z-index:4}@media print{.md-header{display:none}}.md-header[hidden]{transform:translateY(-100%);transition:transform .25s cubic-bezier(.8,0,.6,1)}.md-header--shadow{box-shadow:0 .05rem 0 var(--md-default-fg-color--lightest);transition:transform .25s cubic-bezier(.1,.7,.1,1)}.md-header__inner{align-items:center;display:flex;padding:0 .4rem}.md-header__button{color:currentcolor;cursor:pointer;margin:.2rem;outline-color:var(--md-accent-fg-color);padding:.4rem;position:relative;transition:opacity .25s;vertical-align:middle;z-index:1}.md-header__button:hover{opacity:.7}.md-header__button:not([hidden]){display:inline-block}.md-header__button:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}.md-header__button.md-logo{margin:.2rem;padding:.4rem}@media screen and (max-width:76.234375em){.md-header__button.md-logo{display:none}}.md-header__button.md-logo img,.md-header__button.md-logo svg{fill:currentcolor;display:block;height:1.2rem;width:auto}.md-header__button.md-logo img.lucide,.md-header__button.md-logo svg.lucide{fill:#0000;stroke:currentcolor}@media screen and (min-width:60em){.md-header__button[for=__search]{display:none}}.no-js .md-header__button[for=__search]{display:none}[dir=rtl] .md-header__button[for=__search] svg{transform:scaleX(-1)}@media screen and (min-width:76.25em){.md-header__button[for=__drawer]{display:none}}.md-header__topic{display:flex;max-width:100%;position:absolute;transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .15s;white-space:nowrap}.md-header__topic+.md-header__topic{opacity:0;pointer-events:none;transform:translateX(1.25rem);transition:transform .4s cubic-bezier(1,.7,.1,.1),opacity .15s;z-index:-1}[dir=rtl] .md-header__topic+.md-header__topic{transform:translateX(-1.25rem)}.md-header__topic:first-child{font-weight:700}.md-header__title{flex-grow:1;font-size:.9rem;height:2.4rem;letter-spacing:-.025em;line-height:2.4rem;margin-left:.4rem;margin-right:.4rem}.md-header__title--active .md-header__topic{opacity:0;pointer-events:none;transform:translateX(-1.25rem);transition:transform .4s cubic-bezier(1,.7,.1,.1),opacity .15s;z-index:-1}[dir=rtl] .md-header__title--active .md-header__topic{transform:translateX(1.25rem)}.md-header__title--active .md-header__topic+.md-header__topic{opacity:1;pointer-events:auto;transform:translateX(0);transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .15s;z-index:0}.md-header__title>.md-header__ellipsis{height:100%;position:relative;width:100%}.md-header__option{display:flex;flex-shrink:0;max-width:100%;white-space:nowrap}.md-header__option>input{bottom:0}.md-header__source{display:none}@media screen and (min-width:60em){[dir=ltr] .md-header__source{margin-left:1rem}[dir=rtl] .md-header__source{margin-right:1rem}.md-header__source{display:block;max-width:11.5rem;width:11.5rem}}@media screen and (min-width:76.25em){[dir=ltr] .md-header__source{margin-left:1.4rem}[dir=rtl] .md-header__source{margin-right:1.4rem}}.md-header .md-icon svg{height:1rem;width:1rem}:root{--md-nav-icon--next:url('data:image/svg+xml;charset=utf-8,')}.md-nav{font-size:.7rem;line-height:1.3;transition:max-height .25s cubic-bezier(.86,0,.07,1)}.md-nav .md-nav__title{display:none}.md-nav__list{display:flex;flex-direction:column;gap:.2rem;list-style:none;margin:0;padding:0}[dir=ltr] .md-nav__list .md-nav__list{margin-left:.6rem}[dir=rtl] .md-nav__list .md-nav__list{margin-right:.6rem}.md-nav__item--nested .md-nav__list:after,.md-nav__item--nested .md-nav__list:before{content:" ";display:block;height:0}.md-nav__link{align-items:flex-start;border-radius:.4rem;cursor:pointer;display:flex;gap:.6rem;margin-left:.2rem;margin-right:.2rem;padding:.35rem .8rem;transition:color .25s,background-color .25s}.md-nav__link .md-nav__link{margin:0}.md-nav__link--passed,.md-nav__link--passed code{color:var(--md-default-fg-color--light)}.md-nav__item .md-nav__link--active{font-weight:500}.md-nav--primary .md-nav__item .md-nav__link--active{background:var(--md-accent-fg-color--transparent);color:var(--md-accent-fg-color)}.md-nav__item .md-nav__link--active,.md-nav__item .md-nav__link--active code{color:var(--md-typeset-a-color)}.md-nav__item .md-nav__link--active code svg,.md-nav__item .md-nav__link--active svg{opacity:1}[dir=ltr] .md-nav__item--nested>.md-nav__link:not(.md-nav__container){padding-right:.35rem}[dir=rtl] .md-nav__item--nested>.md-nav__link:not(.md-nav__container){padding-left:.35rem}.md-nav__link .md-ellipsis{flex-grow:1;position:relative}.md-nav__link .md-ellipsis code{word-break:normal}.md-nav__link svg{fill:currentcolor;flex-shrink:0;height:1.3em;opacity:.5;position:relative;width:1.3em}.md-nav__link svg.lucide{fill:#0000;stroke:currentcolor}.md-nav--primary .md-nav__link[for]:focus:not(.md-nav__link--active),.md-nav--primary .md-nav__link[for]:hover:not(.md-nav__link--active),.md-nav--primary .md-nav__link[href]:focus:not(.md-nav__link--active),.md-nav--primary .md-nav__link[href]:hover:not(.md-nav__link--active){background-color:var(--md-default-fg-color--lightest);color:var(--md-default-fg-color)}.md-nav--secondary .md-nav__link{margin-left:.2rem;margin-right:.2rem;padding:.35rem .8rem}.md-nav--secondary .md-nav__link[for]:focus,.md-nav--secondary .md-nav__link[for]:hover,.md-nav--secondary .md-nav__link[href]:focus,.md-nav--secondary .md-nav__link[href]:hover{background-color:initial;color:var(--md-accent-fg-color)}.md-nav__link.focus-visible{outline-color:var(--md-accent-fg-color)}.md-nav--primary .md-nav__link[for=__toc],.md-nav--primary .md-nav__link[for=__toc]~.md-nav{display:none}.md-nav__icon{font-size:.9rem;height:.9rem;width:.9rem}[dir=rtl] .md-nav__icon:after{transform:rotate(180deg)}.md-nav__item--nested .md-nav__icon:after{background-color:currentcolor;content:"";display:block;height:100%;-webkit-mask-image:var(--md-nav-icon--next);mask-image:var(--md-nav-icon--next);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:transform .25s;width:100%}@media screen and (min-width:76.25em){.md-nav__item--nested.md-nav__item--section>.md-nav__link .md-nav__icon:after{display:none}}.md-nav__item--nested .md-nav__toggle:checked~.md-nav__link .md-nav__icon:after,.md-nav__item--nested .md-toggle--indeterminate~.md-nav__link .md-nav__icon:after{transform:rotate(90deg)}.md-nav__container{background:#0000;gap:.2rem;padding:0}.md-nav__container>:first-child{flex-grow:1;min-width:0}.md-nav__container>:nth-child(2){padding:.35rem}@media screen and (min-width:76.25em){.md-nav__item--section>.md-nav__container>:nth-child(2){display:none}}.md-nav__container__icon{flex-shrink:0}.md-nav__toggle~.md-nav{display:grid;grid-template-rows:minmax(.005rem,0fr);opacity:0;transition:grid-template-rows .25s cubic-bezier(.86,0,.07,1),opacity .25s,visibility 0ms .25s;visibility:collapse}.md-nav__toggle~.md-nav>.md-nav__list{overflow:hidden}.md-nav__toggle.md-toggle--indeterminate~.md-nav,.md-nav__toggle:checked~.md-nav{grid-template-rows:minmax(.4rem,1fr);opacity:1;transition:grid-template-rows .25s cubic-bezier(.86,0,.07,1),opacity .15s .1s,visibility 0ms;visibility:visible}.md-nav__toggle.md-toggle--indeterminate~.md-nav{transition:none}.md-nav--secondary{margin-bottom:.1rem;margin-top:.1rem}.md-nav--secondary .md-nav{margin-top:.2rem}.md-nav--secondary .md-nav__title{background:var(--md-default-bg-color);display:flex;font-weight:700;margin-left:.2rem;margin-right:.2rem;padding:.35rem .6rem;position:sticky;top:0;z-index:1}.md-nav--secondary .md-nav__title .md-nav__icon{display:none}.md-nav--secondary .md-nav__link{padding:.2rem .6rem}@media screen and (max-width:76.234375em){.md-nav--primary{margin-bottom:.4rem;margin-left:.2rem;margin-right:.2rem}.md-nav .md-nav__title[for=__drawer]{align-items:center;border-bottom:.05rem solid var(--md-default-fg-color-lightest);display:flex;font-size:.8rem;font-weight:700;gap:.4rem;padding:.8rem}.md-nav .md-nav__title[for=__drawer] .md-logo{height:1.6rem;width:1.6rem}.md-nav .md-nav__title[for=__drawer] .md-logo img,.md-nav .md-nav__title[for=__drawer] .md-logo svg{fill:currentcolor;display:block;height:100%;max-width:100%;object-fit:contain;width:auto}.md-nav .md-nav__title[for=__drawer] .md-logo img.lucide,.md-nav .md-nav__title[for=__drawer] .md-logo svg.lucide{fill:#0000;stroke:currentcolor}}.md-nav__source{border:.05rem solid var(--md-default-fg-color--lightest);border-radius:.4rem;margin:.2rem .2rem .6rem;transition:background-color .25s,border-color .25s}.md-nav__source:focus,.md-nav__source:hover{background-color:var(--md-default-fg-color--lightest);border-color:#0000}[dir=ltr] .md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{margin-left:1.1rem}[dir=rtl] .md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{margin-right:1.1rem}[dir=ltr] .md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{border-left:.05rem solid var(--md-default-fg-color--lightest)}[dir=rtl] .md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{border-right:.05rem solid var(--md-default-fg-color--lightest)}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary{display:block;margin-bottom:.5em;margin-top:.5em;opacity:1;visibility:visible}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary .md-nav__link{background:#0000}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary .md-nav__link--active{font-weight:500}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary .md-nav__link:focus,.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary .md-nav__link:hover{color:var(--md-accent-fg-color)}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary>.md-nav__list{margin-left:0;overflow:visible;padding-bottom:0}.md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary>.md-nav__title{display:none}@media screen and (min-width:76.25em){.md-nav--primary{margin-bottom:.1rem;margin-top:.1rem}.md-nav__source{display:none}[dir=ltr] .md-nav__list .md-nav__item--section>.md-nav>.md-nav__list{margin-left:0}[dir=rtl] .md-nav__list .md-nav__item--section>.md-nav>.md-nav__list{margin-right:0}.md-nav__item--section>.md-nav__link--active,.md-nav__item--section>.md-nav__link>.md-nav__link--active{font-weight:700}.md-nav__item--section{margin-top:.4rem}.md-nav__item--section:first-child{margin-top:0}.md-nav__item--section:last-child{margin-bottom:0}.md-nav__item--section>.md-nav__link{font-weight:700}.md-nav__item--section>.md-nav__link:not(.md-nav__container){pointer-events:none}.md-nav__item--section>.md-nav{display:block;opacity:1;visibility:visible}.md-nav__item--section>.md-nav>.md-nav__list>.md-nav__item{padding:0}.md-nav--lifted{margin-top:0}.md-nav--lifted>.md-nav__list>.md-nav__item{display:none}.md-nav--lifted>.md-nav__list>.md-nav__item--active{display:block}.md-nav--lifted>.md-nav__list>.md-nav__item--active>.md-nav{margin-top:.1rem}.md-nav--lifted>.md-nav__list>.md-nav__item--active>.md-nav>.md-nav__list:before,.md-nav--lifted>.md-nav__list>.md-nav__item--active>.md-nav__link{display:none}.md-nav--lifted>.md-nav__list>.md-nav__item--active.md-nav__item--section{margin:0}.md-nav--lifted .md-nav[data-md-level="1"]{grid-template-rows:minmax(.4rem,1fr);opacity:1;visibility:visible}}:root{--md-path-icon:url('data:image/svg+xml;charset=utf-8,')}.md-path{font-size:.7rem;margin:.4rem .8rem 0;overflow:auto;padding-top:1.2rem}.md-path:not([hidden]){display:block}@media screen and (min-width:76.25em){.md-path{margin:.4rem 1.2rem 0}}.md-path__list{align-items:center;display:flex;gap:.2rem;list-style:none;margin:0;padding:0}.md-path__item:not(:first-child){align-items:center;display:inline-flex;gap:.2rem;white-space:nowrap}.md-path__item:not(:first-child):before{background-color:var(--md-default-fg-color--lighter);content:"";display:inline;height:.6rem;-webkit-mask-image:var(--md-path-icon);mask-image:var(--md-path-icon);width:.6rem}.md-path__link{align-items:center;color:var(--md-default-fg-color--light);display:flex;transition:color .25s}.md-path__link:focus,.md-path__link:hover{color:var(--md-accent-fg-color)}:root{--md-progress-value:0;--md-progress-delay:400ms}.md-progress{background:var(--md-primary-bg-color);height:.075rem;opacity:min(clamp(0,var(--md-progress-value),1),clamp(0,100 - var(--md-progress-value),1));position:fixed;top:0;transform:scaleX(calc(var(--md-progress-value)*1%));transform-origin:left;transition:transform .5s cubic-bezier(.19,1,.22,1),opacity .25s var(--md-progress-delay);width:100%;z-index:4}:root{--md-search-icon:url('data:image/svg+xml;charset=utf-8,')}.md-search{position:relative}@media screen and (min-width:45em){.md-search{padding:.2rem 0}}@media screen and (max-width:59.984375em){.md-search{display:none}}.no-js .md-search{display:none}[dir=ltr] .md-search__button{padding-left:1.9rem;padding-right:2.2rem}[dir=rtl] .md-search__button{padding-left:2.2rem;padding-right:1.9rem}.md-search__button{background:var(--md-default-bg-color);color:var(--md-default-fg-color);cursor:pointer;font-size:.7rem;position:relative;text-align:left}@media screen and (min-width:45em){.md-search__button{background-color:var(--md-default-fg-color--lightest);border-radius:.4rem;height:1.6rem;transition:background-color .4s,color .4s;width:8.9rem}.md-search__button:focus,.md-search__button:hover{background-color:var(--md-default-fg-color--lighter);color:var(--md-default-fg-color)}}[dir=ltr] .md-search__button:before{left:0}[dir=rtl] .md-search__button:before{right:0}.md-search__button:before{background-color:var(--md-default-fg-color);content:"";height:1rem;margin-left:.5rem;-webkit-mask-image:var(--md-search-icon);mask-image:var(--md-search-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.3rem;width:1rem}.md-search__button:after{background:var(--md-default-bg-color--light);border-radius:.2rem;content:"Ctrl+K";display:block;font-size:.6rem;padding:.1rem .2rem;position:absolute;right:.6rem;top:.35rem}[data-platform^=Mac] .md-search__button:after{content:"⌘K"}.md-select{position:relative;z-index:1}.md-select__inner{background-color:var(--md-default-bg-color);border-radius:.4rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color);left:50%;margin-top:.2rem;max-height:0;opacity:0;position:absolute;top:calc(100% - .2rem);transform:translate3d(-50%,.3rem,0);transition:transform .25s 375ms,opacity .25s .25s,max-height 0ms .5s}@media screen and (max-width:59.984375em){.md-select__inner{left:100%;transform:translate3d(-100%,.3rem,0)}}.md-select:focus-within .md-select__inner,.md-select:hover .md-select__inner{max-height:min(75vh,28rem);opacity:1;transform:translate3d(-50%,0,0);transition:transform .25s cubic-bezier(.1,.7,.1,1),opacity .25s,max-height 0ms}@media screen and (max-width:59.984375em){.md-select:focus-within .md-select__inner,.md-select:hover .md-select__inner{transform:translate3d(-100%,0,0)}}.md-select__inner:after{border-bottom:.2rem solid #0000;border-bottom-color:var(--md-default-bg-color);border-left:.2rem solid #0000;border-right:.2rem solid #0000;border-top:0;content:"";filter:drop-shadow(0 -1px 0 var(--md-default-fg-color--lightest));height:0;left:50%;margin-left:-.2rem;margin-top:-.2rem;position:absolute;top:0;width:0}@media screen and (max-width:59.984375em){.md-select__inner:after{left:auto;right:1rem}}.md-select__list{border-radius:.1rem;font-size:.8rem;list-style-type:none;margin:0;max-height:inherit;overflow:auto;padding:0}.md-select__item{line-height:1.8rem}[dir=ltr] .md-select__link{padding-left:.6rem;padding-right:1.2rem}[dir=rtl] .md-select__link{padding-left:1.2rem;padding-right:.6rem}.md-select__link{cursor:pointer;display:block;outline:none;scroll-snap-align:start;transition:background-color .25s,color .25s;width:100%}.md-select__link:focus,.md-select__link:hover{color:var(--md-accent-fg-color)}.md-select__link:focus{background-color:var(--md-default-fg-color--lightest)}:root{--md-toc-icon:url('data:image/svg+xml;charset=utf-8,')}.md-sidebar{align-self:flex-start;flex-shrink:0;padding:1.1rem 0;position:sticky;top:2.4rem;width:12.1rem}@media print{.md-sidebar{display:none}}@media screen and (max-width:76.234375em){[dir=ltr] .md-sidebar--primary{left:-12.1rem}[dir=rtl] .md-sidebar--primary{right:-12.1rem}.md-sidebar--primary{-webkit-backdrop-filter:blur(.4rem);backdrop-filter:blur(.4rem);background-color:var(--md-default-bg-color--light);border-radius:.8rem;display:block;height:calc(100% - .8rem);position:fixed;top:.4rem;transform:translateX(0);transition:transform .2s cubic-bezier(.5,0,.5,0),box-shadow .2s;width:12.1rem;z-index:5}[data-md-toggle=drawer]:checked~.md-container .md-sidebar--primary{box-shadow:var(--md-shadow-z3);transform:translateX(12.5rem);transition:transform .25s cubic-bezier(.7,.7,.1,1),box-shadow .25s}[dir=rtl] [data-md-toggle=drawer]:checked~.md-container .md-sidebar--primary{transform:translateX(-12.5rem)}.md-sidebar--primary .md-sidebar__scrollwrap{bottom:0;left:0;margin:0;overscroll-behavior-y:contain;position:absolute;right:0;top:0}}@media screen and (min-width:76.25em){.md-sidebar{height:0}.no-js .md-sidebar{height:auto}.md-header--lifted~.md-container .md-sidebar{top:4.8rem}}.md-sidebar--secondary{order:2}@media screen and (max-width:59.984375em){.md-sidebar--secondary{bottom:1.6rem;padding:0;position:fixed;right:.8rem;top:auto;width:auto;z-index:2}.md-sidebar--secondary .md-nav--secondary{margin-top:0}.md-sidebar--secondary .md-nav__title{padding:.55rem .6rem .35rem}.md-sidebar--secondary .md-sidebar__scrollwrap{display:flex;flex-direction:column-reverse;overflow-y:visible;position:relative}.md-sidebar--secondary .md-sidebar__inner{background-color:var(--md-default-bg-color);border-radius:.4rem;bottom:2.7rem;box-shadow:var(--md-shadow-z2);max-height:50vh;opacity:0;overflow-y:auto;padding-bottom:.4rem;pointer-events:none;position:absolute;right:0;transform:translateY(.4rem);transition:transform 0ms .25s,opacity .25s;width:11.7rem}.md-sidebar--secondary [type=checkbox]:checked~.md-sidebar__inner{opacity:1;pointer-events:auto;transform:translateY(0);transition:transform .4s cubic-bezier(0,1,.35,1),opacity .25s,z-index 0ms}.md-sidebar--secondary .md-sidebar-button{-webkit-backdrop-filter:blur(.4rem);backdrop-filter:blur(.4rem);background-color:var(--md-default-bg-color--light);border-radius:1.6rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color--light);cursor:pointer;display:inline-flex;font-size:.7rem;gap:.4rem;outline:none;padding:.5rem;transition:color 125ms,background-color 125ms,transform 125ms cubic-bezier(.4,0,.2,1),opacity 125ms}.md-sidebar--secondary .md-sidebar-button:after{background-color:currentcolor;content:"";display:block;height:.9rem;-webkit-mask-image:var(--md-toc-icon);mask-image:var(--md-toc-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:transform .25s;width:.9rem}.md-sidebar--secondary .md-sidebar-button:focus,.md-sidebar--secondary .md-sidebar-button:hover{background-color:var(--md-accent-fg-color);color:var(--md-accent-bg-color)}.md-sidebar--secondary .md-sidebar-button__wrapper{text-align:right}}@media screen and (min-width:60em){.md-sidebar--secondary{height:0}.md-sidebar--secondary .md-sidebar-button{display:none}.no-js .md-sidebar--secondary{height:auto}.md-sidebar--secondary:not([hidden]){display:block}.md-sidebar--secondary .md-sidebar__scrollwrap{touch-action:pan-y}}.md-sidebar__scrollwrap{backface-visibility:hidden;overflow-y:auto;scrollbar-color:var(--md-default-fg-color--lighter) #0000}@media screen and (min-width:60em){.md-sidebar__scrollwrap{scrollbar-gutter:stable;scrollbar-width:thin}}.md-sidebar__scrollwrap::-webkit-scrollbar{height:.2rem;width:.2rem}.md-sidebar__scrollwrap:focus-within,.md-sidebar__scrollwrap:hover{scrollbar-color:var(--md-accent-fg-color) #0000}.md-sidebar__scrollwrap:focus-within::-webkit-scrollbar-thumb,.md-sidebar__scrollwrap:hover::-webkit-scrollbar-thumb{background-color:var(--md-default-fg-color--lighter)}.md-sidebar__scrollwrap:focus-within::-webkit-scrollbar-thumb:hover,.md-sidebar__scrollwrap:hover::-webkit-scrollbar-thumb:hover{background-color:var(--md-accent-fg-color)}@supports selector(::-webkit-scrollbar){.md-sidebar__scrollwrap{scrollbar-gutter:auto}[dir=ltr] .md-sidebar__inner{padding-right:calc(100% - 11.5rem)}[dir=rtl] .md-sidebar__inner{padding-left:calc(100% - 11.5rem)}@media screen and (max-width:76.234375em){[dir=ltr] .md-sidebar__inner{padding-right:0}[dir=rtl] .md-sidebar__inner{padding-left:0}}}@media screen and (max-width:76.234375em){.md-overlay{-webkit-backdrop-filter:blur(.2rem);backdrop-filter:blur(.2rem);background-color:var(--md-default-bg-color--light);height:0;opacity:0;position:fixed;top:0;transition:width 0ms .5s,height 0ms .5s,opacity .25s 125ms;width:0;z-index:5}[data-md-toggle=drawer]:checked~.md-overlay{height:100%;opacity:1;transition:width 0ms,height 0ms,opacity .25s;width:100%}}@keyframes facts{0%{height:0}to{height:.65rem}}@keyframes fact{0%{opacity:0;transform:translateY(100%)}50%{opacity:0}to{opacity:1;transform:translateY(0)}}:root{--md-source-forks-icon:url('data:image/svg+xml;charset=utf-8,');--md-source-repositories-icon:url('data:image/svg+xml;charset=utf-8,');--md-source-stars-icon:url('data:image/svg+xml;charset=utf-8,');--md-source-version-icon:url('data:image/svg+xml;charset=utf-8,')}.md-source{backface-visibility:hidden;display:block;font-size:.55rem;line-height:1.2;outline-color:var(--md-accent-fg-color);transition:opacity .25s;white-space:nowrap}.md-source:hover{opacity:.7}.md-source__icon{display:inline-block;height:2.4rem;vertical-align:middle;width:2rem}[dir=ltr] .md-source__icon svg{margin-left:.6rem}[dir=rtl] .md-source__icon svg{margin-right:.6rem}.md-source__icon svg{margin-top:.6rem}.md-header .md-source__icon svg{height:1.2rem;width:1.2rem}[dir=ltr] .md-source__icon+.md-source__repository{padding-left:2rem}[dir=rtl] .md-source__icon+.md-source__repository{padding-right:2rem}[dir=ltr] .md-source__icon+.md-source__repository{margin-left:-2rem}[dir=rtl] .md-source__icon+.md-source__repository{margin-right:-2rem}[dir=ltr] .md-source__repository{margin-left:.6rem}[dir=rtl] .md-source__repository{margin-right:.6rem}.md-source__repository{display:inline-block;max-width:calc(100% - 1.2rem);overflow:hidden;text-overflow:ellipsis;vertical-align:middle}.md-source__facts{display:flex;font-size:.55rem;gap:.4rem;list-style-type:none;margin:.1rem 0 0;opacity:.75;overflow:hidden;padding:0;width:100%}.md-source__repository--active .md-source__facts{animation:facts 0ms ease-in}.md-source__fact{overflow:hidden;text-overflow:ellipsis}.md-source__repository--active .md-source__fact{animation:fact 0ms ease-out}[dir=ltr] .md-source__fact:before{margin-right:.1rem}[dir=rtl] .md-source__fact:before{margin-left:.1rem}.md-source__fact:before{background-color:currentcolor;content:"";display:inline-block;height:.6rem;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;vertical-align:text-top;width:.6rem}.md-source__fact:nth-child(1n+2){flex-shrink:0}.md-source__fact--version:before{-webkit-mask-image:var(--md-source-version-icon);mask-image:var(--md-source-version-icon)}.md-source__fact--stars:before{-webkit-mask-image:var(--md-source-stars-icon);mask-image:var(--md-source-stars-icon)}.md-source__fact--forks:before{-webkit-mask-image:var(--md-source-forks-icon);mask-image:var(--md-source-forks-icon)}.md-source__fact--repositories:before{-webkit-mask-image:var(--md-source-repositories-icon);mask-image:var(--md-source-repositories-icon)}.md-source-file{margin:1em 0}[dir=ltr] .md-source-file__fact{margin-right:.6rem}[dir=rtl] .md-source-file__fact{margin-left:.6rem}.md-source-file__fact{align-items:center;color:var(--md-default-fg-color--light);display:inline-flex;font-size:.68rem;gap:.3rem}.md-source-file__fact .md-icon{flex-shrink:0;margin-bottom:.05rem}[dir=ltr] .md-source-file__fact .md-author{float:left}[dir=rtl] .md-source-file__fact .md-author{float:right}.md-source-file__fact .md-author{margin-right:.2rem}.md-source-file__fact svg{width:.9rem}:root{--md-status:url('data:image/svg+xml;charset=utf-8,');--md-status--new:url('data:image/svg+xml;charset=utf-8,');--md-status--deprecated:url('data:image/svg+xml;charset=utf-8,')}.md-status:after{background-color:var(--md-default-fg-color--light);content:"";display:inline-block;height:1.125em;-webkit-mask-image:var(--md-status);mask-image:var(--md-status);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;vertical-align:text-bottom;width:1.125em}.md-status:hover:after{background-color:currentcolor}.md-status--new:after{-webkit-mask-image:var(--md-status--new);mask-image:var(--md-status--new)}.md-status--deprecated:after{-webkit-mask-image:var(--md-status--deprecated);mask-image:var(--md-status--deprecated)}.md-tabs{box-shadow:0 -.05rem 0 inset var(--md-default-fg-color--lightest);color:var(--md-default-fg-color);display:block;line-height:1.3;overflow:auto;width:100%;z-index:2}@media print{.md-tabs{display:none}}@media screen and (max-width:76.234375em){.md-tabs{display:none}}.md-header--lifted .md-tabs{box-shadow:none;margin-bottom:-.05rem}.md-tabs[hidden]{pointer-events:none}[dir=ltr] .md-tabs__list{margin-left:.4rem}[dir=rtl] .md-tabs__list{margin-right:.4rem}.md-tabs__list{contain:content;display:flex;list-style:none;margin:0;overflow:auto;padding:0;scrollbar-width:none;white-space:nowrap}.md-tabs__list::-webkit-scrollbar{display:none}.md-tabs__item{height:2.4rem;padding-left:.6rem;padding-right:.6rem}.md-tabs__item--active{border-bottom:.05rem solid var(--md-default-fg-color);font-weight:500;position:relative;transition:border-bottom .25s}.md-tabs[hidden] .md-tabs__item--active{border-bottom:.05rem solid #0000}.md-tabs__item--active .md-tabs__link{color:inherit;opacity:1}.md-tabs__link{backface-visibility:hidden;display:flex;font-size:.7rem;margin-top:.8rem;opacity:.7;outline-color:var(--md-accent-fg-color);outline-offset:.2rem;transition:transform .4s cubic-bezier(.1,.7,.1,1),opacity .25s}.md-tabs__link:focus,.md-tabs__link:hover{color:inherit;opacity:1}[dir=ltr] .md-tabs__link svg{margin-right:.4rem}[dir=rtl] .md-tabs__link svg{margin-left:.4rem}.md-tabs__link svg{fill:currentcolor;height:1.3em}.md-tabs__item:nth-child(2) .md-tabs__link{transition-delay:20ms}.md-tabs__item:nth-child(3) .md-tabs__link{transition-delay:40ms}.md-tabs__item:nth-child(4) .md-tabs__link{transition-delay:60ms}.md-tabs__item:nth-child(5) .md-tabs__link{transition-delay:80ms}.md-tabs__item:nth-child(6) .md-tabs__link{transition-delay:.1s}.md-tabs__item:nth-child(7) .md-tabs__link{transition-delay:.12s}.md-tabs__item:nth-child(8) .md-tabs__link{transition-delay:.14s}.md-tabs__item:nth-child(9) .md-tabs__link{transition-delay:.16s}.md-tabs__item:nth-child(10) .md-tabs__link{transition-delay:.18s}.md-tabs__item:nth-child(11) .md-tabs__link{transition-delay:.2s}.md-tabs__item:nth-child(12) .md-tabs__link{transition-delay:.22s}.md-tabs__item:nth-child(13) .md-tabs__link{transition-delay:.24s}.md-tabs__item:nth-child(14) .md-tabs__link{transition-delay:.26s}.md-tabs__item:nth-child(15) .md-tabs__link{transition-delay:.28s}.md-tabs__item:nth-child(16) .md-tabs__link{transition-delay:.3s}.md-tabs[hidden] .md-tabs__link{opacity:0;transform:translateY(50%);transition:transform 0ms .1s,opacity .1s}:root{--md-tag-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .md-tags:not([hidden]){display:flex;flex-wrap:wrap;gap:.5em;margin-bottom:1.2rem;margin-top:.8rem;padding-top:1.2rem}.md-typeset .md-tag{align-items:center;background:var(--md-default-fg-color--lightest);border-radius:.4rem;display:inline-flex;font-size:.64rem;font-size:min(.8em,.64rem);font-weight:700;gap:.5em;letter-spacing:normal;line-height:1.6;padding:.3125em .78125em}.md-typeset .md-tag[href]{-webkit-tap-highlight-color:transparent;color:inherit;outline:none;transition:color 125ms,background-color 125ms}.md-typeset .md-tag[href]:focus,.md-typeset .md-tag[href]:hover{background-color:var(--md-accent-fg-color);color:var(--md-accent-bg-color)}[id]>.md-typeset .md-tag{vertical-align:text-top}.md-typeset .md-tag-shadow{opacity:.5}.md-typeset .md-tag-icon:before{background-color:var(--md-default-fg-color--lighter);content:"";display:inline-block;height:1.2em;-webkit-mask-image:var(--md-tag-icon);mask-image:var(--md-tag-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:background-color 125ms;vertical-align:text-bottom;width:1.2em}.md-typeset .md-tag-icon[href]:focus:before,.md-typeset .md-tag-icon[href]:hover:before{background-color:var(--md-accent-bg-color)}@keyframes pulse{0%{transform:scale(.95)}75%{transform:scale(1)}to{transform:scale(.95)}}:root{--md-annotation-bg-icon:url('data:image/svg+xml;charset=utf-8,');--md-annotation-icon:url('data:image/svg+xml;charset=utf-8,')}.md-tooltip{backface-visibility:hidden;background-color:var(--md-default-bg-color);border-radius:.4rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color);font-family:var(--md-text-font-family);left:clamp(var(--md-tooltip-0,0rem) + .8rem,var(--md-tooltip-x) - .1rem,100vw + var(--md-tooltip-0,0rem) + .8rem - var(--md-tooltip-width) - 2 * .8rem);max-width:calc(100vw - 1.6rem);opacity:0;position:absolute;top:calc(var(--md-tooltip-y) - .1rem);transform:translateY(-.4rem);transition:transform 0ms .25s,opacity .25s,z-index .25s;width:var(--md-tooltip-width);z-index:0}.md-tooltip--active{opacity:1;transform:translateY(0);transition:transform .25s cubic-bezier(.1,.7,.1,1),opacity .25s,z-index 0ms;z-index:2}.md-tooltip--inline{font-weight:400;-webkit-user-select:none;user-select:none;width:auto}.md-tooltip--inline:not(.md-tooltip--active){transform:translateY(.2rem) scale(.9)}.md-tooltip--inline .md-tooltip__inner{font-size:.55rem;padding:.2rem .4rem}[hidden]+.md-tooltip--inline{display:none}.focus-visible>.md-tooltip,.md-tooltip:target{outline:var(--md-accent-fg-color) auto}.md-tooltip__inner{font-size:.64rem;padding:.8rem}.md-tooltip__inner.md-typeset>:first-child{margin-top:0}.md-tooltip__inner.md-typeset>:last-child{margin-bottom:0}.md-annotation{font-style:normal;font-weight:400;outline:none;text-align:initial;vertical-align:text-bottom;white-space:normal}[dir=rtl] .md-annotation{direction:rtl}code .md-annotation{font-family:var(--md-code-font-family);font-size:inherit}.md-annotation:not([hidden]){display:inline-block;line-height:1.25}.md-annotation__index{border-radius:.01px;cursor:pointer;display:inline-block;margin-left:.4ch;margin-right:.4ch;outline:none;overflow:hidden;position:relative;-webkit-user-select:none;user-select:none;vertical-align:text-top;z-index:0}.md-annotation .md-annotation__index{transition:z-index .25s}@media screen{.md-annotation__index{width:2.2ch}[data-md-visible]>.md-annotation__index{animation:pulse 2s infinite}.md-annotation__index:before{background:var(--md-default-bg-color);-webkit-mask-image:var(--md-annotation-bg-icon);mask-image:var(--md-annotation-bg-icon)}.md-annotation__index:after,.md-annotation__index:before{content:"";height:2.2ch;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:-.1ch;width:2.2ch;z-index:-1}.md-annotation__index:after{background-color:var(--md-default-fg-color--lighter);-webkit-mask-image:var(--md-annotation-icon);mask-image:var(--md-annotation-icon);transform:scale(1.0001);transition:background-color .25s,transform .25s}.md-tooltip--active+.md-annotation__index:after{transform:rotate(45deg)}.md-tooltip--active+.md-annotation__index:after,:hover>.md-annotation__index:after{background-color:var(--md-accent-fg-color)}}.md-tooltip--active+.md-annotation__index{animation-play-state:paused;transition-duration:0ms;z-index:2}.md-annotation__index [data-md-annotation-id]{display:inline-block}@media print{.md-annotation__index [data-md-annotation-id]{background:var(--md-default-fg-color--lighter);border-radius:2ch;color:var(--md-default-bg-color);font-weight:700;padding:0 .6ch;white-space:nowrap}.md-annotation__index [data-md-annotation-id]:after{content:attr(data-md-annotation-id)}}.md-typeset .md-annotation-list{counter-reset:annotation;list-style:none!important}.md-typeset .md-annotation-list li{position:relative}[dir=ltr] .md-typeset .md-annotation-list li:before{left:-2.125em}[dir=rtl] .md-typeset .md-annotation-list li:before{right:-2.125em}.md-typeset .md-annotation-list li:before{background:var(--md-default-fg-color--lighter);border-radius:2ch;color:var(--md-default-bg-color);content:counter(annotation);counter-increment:annotation;font-size:.8875em;font-weight:700;height:2ch;line-height:1.25;min-width:2ch;padding:0 .6ch;position:absolute;text-align:center;top:.25em}:root{--md-tooltip-width:20rem;--md-tooltip-tail:0.3rem}.md-tooltip2{backface-visibility:hidden;color:var(--md-default-fg-color);font-family:var(--md-text-font-family);opacity:0;pointer-events:none;position:absolute;top:calc(var(--md-tooltip-host-y) + var(--md-tooltip-y));transform:translateY(.4rem);transform-origin:calc(var(--md-tooltip-host-x) + var(--md-tooltip-x)) 0;transition:transform 0ms .25s,opacity .25s,z-index .25s;width:100%;z-index:0}.md-tooltip2:before{border-left:var(--md-tooltip-tail) solid #0000;border-right:var(--md-tooltip-tail) solid #0000;content:"";display:block;left:clamp(1.5 * .8rem,var(--md-tooltip-host-x) + var(--md-tooltip-x) - var(--md-tooltip-tail),100vw - 2 * var(--md-tooltip-tail) - 1.5 * .8rem);position:absolute;z-index:1}.md-tooltip2--top:before{border-top:var(--md-tooltip-tail) solid var(--md-default-bg-color);bottom:calc(var(--md-tooltip-tail)*-1 + .025rem);filter:drop-shadow(0 1px 0 var(--md-default-fg-color--lightest))}.md-tooltip2--bottom:before{border-bottom:var(--md-tooltip-tail) solid var(--md-default-bg-color);filter:drop-shadow(0 -1px 0 var(--md-default-fg-color--lightest));top:calc(var(--md-tooltip-tail)*-1 + .025rem)}.md-tooltip2--active{opacity:1;transform:translateY(0);transition:transform .4s cubic-bezier(0,1,.35,1),opacity .25s,z-index 0ms;z-index:4}.md-tooltip2__inner{scrollbar-gutter:stable;background-color:var(--md-default-bg-color);border-radius:.4rem;box-shadow:var(--md-shadow-z2);left:clamp(.8rem,var(--md-tooltip-host-x) - .8rem,100vw - var(--md-tooltip-width) - .8rem);max-height:40vh;max-width:calc(100vw - 1.6rem);position:relative;scrollbar-width:thin}.md-tooltip2__inner::-webkit-scrollbar{height:.2rem;width:.2rem}.md-tooltip2__inner::-webkit-scrollbar-thumb{background-color:var(--md-default-fg-color--lighter)}.md-tooltip2__inner::-webkit-scrollbar-thumb:hover{background-color:var(--md-accent-fg-color)}[role=dialog]>.md-tooltip2__inner{font-size:.64rem;overflow:auto;padding:0 .8rem;pointer-events:auto;width:var(--md-tooltip-width)}[role=dialog]>.md-tooltip2__inner:after,[role=dialog]>.md-tooltip2__inner:before{content:"";display:block;height:.8rem;position:sticky;width:100%;z-index:10}[role=dialog]>.md-tooltip2__inner:before{background:linear-gradient(var(--md-default-bg-color),#0000 75%);top:0}[role=dialog]>.md-tooltip2__inner:after{background:linear-gradient(#0000,var(--md-default-bg-color) 75%);bottom:0}[role=tooltip]>.md-tooltip2__inner{font-size:.55rem;font-weight:400;left:clamp(.8rem,var(--md-tooltip-host-x) + var(--md-tooltip-x) - var(--md-tooltip-width)/2,100vw - var(--md-tooltip-width) - .8rem);max-width:min(100vw - 2 * .8rem,400px);padding:.2rem .4rem;-webkit-user-select:none;user-select:none;width:fit-content}.md-tooltip2__inner.md-typeset>:first-child{margin-top:0}.md-tooltip2__inner.md-typeset>:last-child{margin-bottom:0}[dir=ltr] .md-top{margin-left:50%}[dir=rtl] .md-top{margin-right:50%}.md-top{-webkit-backdrop-filter:blur(.4rem);backdrop-filter:blur(.4rem);background-color:var(--md-default-bg-color--light);border-radius:1.6rem;bottom:1.6rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color--light);cursor:pointer;display:flex;font-size:.7rem;gap:.4rem;outline:none;padding:.5rem .9rem .5rem .7rem;position:fixed;top:auto!important;transform:translate(-50%);transition:color 125ms,background-color 125ms,transform 125ms cubic-bezier(.4,0,.2,1),opacity 125ms;z-index:2}@media print{.md-top{display:none}}[dir=rtl] .md-top{transform:translate(50%)}.md-top[hidden]{opacity:0;pointer-events:none;transform:translate(-50%,.2rem);transition-duration:0ms}[dir=rtl] .md-top[hidden]{transform:translate(50%,.2rem)}.md-top:focus,.md-top:hover{background-color:var(--md-accent-fg-color);color:var(--md-accent-bg-color)}.md-top svg{display:inline-block;height:.9rem;vertical-align:-.5em;width:.9rem}.md-top svg.lucide{fill:#0000;stroke:currentcolor}@keyframes hoverfix{0%{pointer-events:none}}:root{--md-version-icon:url('data:image/svg+xml;charset=utf-8,')}.md-version{flex-shrink:0;font-size:.8rem;height:2.4rem}[dir=ltr] .md-version__current{margin-left:1.4rem;margin-right:.4rem}[dir=rtl] .md-version__current{margin-left:.4rem;margin-right:1.4rem}.md-version__current{color:inherit;cursor:pointer;outline:none;position:relative;top:.05rem}[dir=ltr] .md-version__current:after{margin-left:.4rem}[dir=rtl] .md-version__current:after{margin-right:.4rem}.md-version__current:after{background-color:currentcolor;content:"";display:inline-block;height:.6rem;-webkit-mask-image:var(--md-version-icon);mask-image:var(--md-version-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:.4rem}.md-version__alias{margin-left:.3rem;opacity:.7}.md-version__list{background-color:var(--md-default-bg-color);border-radius:.1rem;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color);list-style-type:none;margin:.2rem .8rem;max-height:0;opacity:0;overflow:auto;padding:0;position:absolute;scroll-snap-type:y mandatory;top:.15rem;transition:max-height 0ms .5s,opacity .25s .25s;z-index:3}.md-version:focus-within .md-version__list,.md-version:hover .md-version__list{max-height:10rem;opacity:1;transition:max-height 0ms,opacity .25s}@media (hover:none),(pointer:coarse){.md-version:hover .md-version__list{animation:hoverfix .25s forwards}.md-version:focus-within .md-version__list{animation:none}}.md-version__item{line-height:1.8rem}[dir=ltr] .md-version__link{padding-left:.6rem;padding-right:1.2rem}[dir=rtl] .md-version__link{padding-left:1.2rem;padding-right:.6rem}.md-version__link{cursor:pointer;display:block;outline:none;scroll-snap-align:start;transition:color .25s,background-color .25s;white-space:nowrap;width:100%}.md-version__link:focus,.md-version__link:hover{color:var(--md-accent-fg-color)}.md-version__link:focus{background-color:var(--md-default-fg-color--lightest)}:root{--md-admonition-icon--note:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--abstract:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--info:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--tip:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--success:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--question:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--warning:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--failure:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--danger:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--bug:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--example:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--quote:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .admonition,.md-typeset details{background-color:#448aff1a;border-radius:.4rem;color:var(--md-admonition-fg-color);display:flow-root;font-size:.64rem;margin:1.5625em 0;padding:0 .8rem;page-break-inside:avoid}.md-typeset .admonition>*,.md-typeset details>*{box-sizing:border-box}.md-typeset .admonition .admonition,.md-typeset .admonition details,.md-typeset details .admonition,.md-typeset details details{margin-bottom:1em;margin-top:1em}.md-typeset .admonition .md-typeset__scrollwrap,.md-typeset details .md-typeset__scrollwrap{margin:1em -.6rem}.md-typeset .admonition .md-typeset__table,.md-typeset details .md-typeset__table{padding:0 .6rem}.md-typeset .admonition>.tabbed-set:only-child,.md-typeset details>.tabbed-set:only-child{margin-top:0}html .md-typeset .admonition>:last-child,html .md-typeset details>:last-child{margin-bottom:.6rem}[dir=ltr] .md-typeset .admonition-title,[dir=ltr] .md-typeset summary{padding-left:1.6rem;padding-right:.8rem}[dir=rtl] .md-typeset .admonition-title,[dir=rtl] .md-typeset summary{padding-left:.8rem;padding-right:1.6rem}.md-typeset .admonition-title,.md-typeset summary{font-weight:700;margin-bottom:1em;margin-top:.6rem;position:relative}[dir=ltr] .md-typeset .admonition-title:before,[dir=ltr] .md-typeset summary:before{left:0}[dir=rtl] .md-typeset .admonition-title:before,[dir=rtl] .md-typeset summary:before{right:0}.md-typeset .admonition-title:before,.md-typeset summary:before{background-color:#448aff;content:"";height:1rem;-webkit-mask-image:var(--md-admonition-icon--note);mask-image:var(--md-admonition-icon--note);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.125em;width:1rem}.md-typeset .admonition.note,.md-typeset details.note{background-color:#448aff1a}.md-typeset .note>.admonition-title:before,.md-typeset .note>summary:before{background-color:#448aff;-webkit-mask-image:var(--md-admonition-icon--note);mask-image:var(--md-admonition-icon--note)}.md-typeset .note>.admonition-title:after,.md-typeset .note>summary:after{color:#448aff}.md-typeset .admonition.abstract,.md-typeset details.abstract{background-color:#00b0ff1a}.md-typeset .abstract>.admonition-title:before,.md-typeset .abstract>summary:before{background-color:#00b0ff;-webkit-mask-image:var(--md-admonition-icon--abstract);mask-image:var(--md-admonition-icon--abstract)}.md-typeset .abstract>.admonition-title:after,.md-typeset .abstract>summary:after{color:#00b0ff}.md-typeset .admonition.info,.md-typeset details.info{background-color:#00b8d41a}.md-typeset .info>.admonition-title:before,.md-typeset .info>summary:before{background-color:#00b8d4;-webkit-mask-image:var(--md-admonition-icon--info);mask-image:var(--md-admonition-icon--info)}.md-typeset .info>.admonition-title:after,.md-typeset .info>summary:after{color:#00b8d4}.md-typeset .admonition.tip,.md-typeset details.tip{background-color:#00bfa51a}.md-typeset .tip>.admonition-title:before,.md-typeset .tip>summary:before{background-color:#00bfa5;-webkit-mask-image:var(--md-admonition-icon--tip);mask-image:var(--md-admonition-icon--tip)}.md-typeset .tip>.admonition-title:after,.md-typeset .tip>summary:after{color:#00bfa5}.md-typeset .admonition.success,.md-typeset details.success{background-color:#00c8531a}.md-typeset .success>.admonition-title:before,.md-typeset .success>summary:before{background-color:#00c853;-webkit-mask-image:var(--md-admonition-icon--success);mask-image:var(--md-admonition-icon--success)}.md-typeset .success>.admonition-title:after,.md-typeset .success>summary:after{color:#00c853}.md-typeset .admonition.question,.md-typeset details.question{background-color:#64dd171a}.md-typeset .question>.admonition-title:before,.md-typeset .question>summary:before{background-color:#64dd17;-webkit-mask-image:var(--md-admonition-icon--question);mask-image:var(--md-admonition-icon--question)}.md-typeset .question>.admonition-title:after,.md-typeset .question>summary:after{color:#64dd17}.md-typeset .admonition.warning,.md-typeset details.warning{background-color:#ff91001a}.md-typeset .warning>.admonition-title:before,.md-typeset .warning>summary:before{background-color:#ff9100;-webkit-mask-image:var(--md-admonition-icon--warning);mask-image:var(--md-admonition-icon--warning)}.md-typeset .warning>.admonition-title:after,.md-typeset .warning>summary:after{color:#ff9100}.md-typeset .admonition.failure,.md-typeset details.failure{background-color:#ff52521a}.md-typeset .failure>.admonition-title:before,.md-typeset .failure>summary:before{background-color:#ff5252;-webkit-mask-image:var(--md-admonition-icon--failure);mask-image:var(--md-admonition-icon--failure)}.md-typeset .failure>.admonition-title:after,.md-typeset .failure>summary:after{color:#ff5252}.md-typeset .admonition.danger,.md-typeset details.danger{background-color:#ff17441a}.md-typeset .danger>.admonition-title:before,.md-typeset .danger>summary:before{background-color:#ff1744;-webkit-mask-image:var(--md-admonition-icon--danger);mask-image:var(--md-admonition-icon--danger)}.md-typeset .danger>.admonition-title:after,.md-typeset .danger>summary:after{color:#ff1744}.md-typeset .admonition.bug,.md-typeset details.bug{background-color:#f500571a}.md-typeset .bug>.admonition-title:before,.md-typeset .bug>summary:before{background-color:#f50057;-webkit-mask-image:var(--md-admonition-icon--bug);mask-image:var(--md-admonition-icon--bug)}.md-typeset .bug>.admonition-title:after,.md-typeset .bug>summary:after{color:#f50057}.md-typeset .admonition.example,.md-typeset details.example{background-color:#7c4dff1a}.md-typeset .example>.admonition-title:before,.md-typeset .example>summary:before{background-color:#7c4dff;-webkit-mask-image:var(--md-admonition-icon--example);mask-image:var(--md-admonition-icon--example)}.md-typeset .example>.admonition-title:after,.md-typeset .example>summary:after{color:#7c4dff}.md-typeset .admonition.quote,.md-typeset details.quote{background-color:#9e9e9e1a}.md-typeset .quote>.admonition-title:before,.md-typeset .quote>summary:before{background-color:#9e9e9e;-webkit-mask-image:var(--md-admonition-icon--quote);mask-image:var(--md-admonition-icon--quote)}.md-typeset .quote>.admonition-title:after,.md-typeset .quote>summary:after{color:#9e9e9e}:root{--md-footnotes-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .footnote{color:var(--md-default-fg-color--light);font-size:.64rem}[dir=ltr] .md-typeset .footnote>ol{margin-left:0}[dir=rtl] .md-typeset .footnote>ol{margin-right:0}.md-typeset .footnote>ol>li{transition:color 125ms}.md-typeset .footnote>ol>li:target{color:var(--md-default-fg-color)}.md-typeset .footnote>ol>li:focus-within .footnote-backref{opacity:1;transform:translateY(0);transition:none}.md-typeset .footnote>ol>li:hover .footnote-backref,.md-typeset .footnote>ol>li:target .footnote-backref{opacity:1;transform:translateY(0)}.md-typeset .footnote>ol>li>:first-child{margin-top:0}.md-typeset .footnote-ref{font-size:.75em;font-weight:700;text-decoration:none}html .md-typeset .footnote-ref{outline-offset:.1rem}.md-typeset [id^="fnref:"]:target>.footnote-ref{outline:auto}.md-typeset .footnote-backref{color:var(--md-typeset-a-color);display:inline-block;font-size:0;opacity:0;transform:translateY(.25rem);transition:color .25s,transform .25s .25s,opacity 125ms .25s;vertical-align:text-bottom}@media print{.md-typeset .footnote-backref{color:var(--md-typeset-a-color);opacity:1;transform:translateY(0)}}[dir=rtl] .md-typeset .footnote-backref{transform:translateY(-.25rem)}.md-typeset .footnote-backref:hover{color:var(--md-accent-fg-color)}.md-typeset .footnote-backref:before{background-color:currentcolor;content:"";display:inline-block;height:.8rem;-webkit-mask-image:var(--md-footnotes-icon);mask-image:var(--md-footnotes-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;width:.8rem}[dir=rtl] .md-typeset .footnote-backref:before{transform:scaleX(-1)}[dir=ltr] .md-typeset .headerlink{margin-left:.5rem}[dir=rtl] .md-typeset .headerlink{margin-right:.5rem}.md-typeset .headerlink{color:var(--md-default-fg-color--lighter);display:inline-block;opacity:0;text-decoration:none;transition:color .25s,opacity 125ms}@media print{.md-typeset .headerlink{display:none}}.md-typeset .headerlink:focus,.md-typeset :hover>.headerlink,.md-typeset :target>.headerlink{opacity:1;transition:color .25s,opacity 125ms}.md-typeset .headerlink:focus,.md-typeset .headerlink:hover,.md-typeset :target>.headerlink{color:var(--md-accent-fg-color)}.md-typeset :target{--md-scroll-margin:3.6rem;--md-scroll-offset:0rem;scroll-margin-top:calc(var(--md-scroll-margin) - var(--md-scroll-offset))}@media screen and (min-width:76.25em){.md-header--lifted~.md-container .md-typeset :target{--md-scroll-margin:6rem}}.md-typeset h1:target{--md-scroll-offset:0.1rem}.md-typeset h3:target,.md-typeset h4:target{--md-scroll-offset:-0.1rem}:root{--md-admonition-icon--mkdocstrings:url('data:image/svg+xml;charset=utf-8,');--md-admonition-icon--mkdocstrings-open:url('data:image/svg+xml;charset=utf-8,')}.doc-object-name{font-family:var(--md-code-font-family)}code.doc-symbol-heading{margin-right:.4rem;padding:0}[dir=ltr] .doc-labels{margin-left:.4rem}[dir=rtl] .doc-labels{margin-right:.4rem}.doc-label code{background:#0000;border:1px solid var(--md-default-fg-color--lightest);border-radius:.5rem;color:var(--md-default-fg-color--light);font-weight:400;padding-left:.3rem;padding-right:.3rem;vertical-align:text-bottom}.doc-contents td code{word-break:normal!important}.doc-md-description,.doc-md-description>p:first-child{display:inline}.md-typeset h5 .doc-object-name{text-transform:none}.doc .md-typeset__table,.doc .md-typeset__table table{display:table!important;width:100%}.doc .md-typeset__table tr{display:table-row}.doc-param-default,.doc-type_param-default{float:right}.doc-heading-parameter,.doc-heading-type_parameter{display:inline}.md-typeset .doc-heading-parameter{font-size:inherit}.doc-heading-parameter .headerlink,.doc-heading-type_parameter .headerlink{margin-left:0!important;margin-right:.2rem}.doc-section-title{font-weight:700}.doc-signature .autorefs{color:inherit;text-decoration-style:dotted}div.doc-contents:not(.first){border-left:.05rem solid var(--md-code-bg-color);margin-left:.4rem;padding-left:.8rem}:host,:root,[data-md-color-scheme=default]{--doc-symbol-parameter-fg-color:#829bd1;--doc-symbol-type_parameter-fg-color:#829bd1;--doc-symbol-attribute-fg-color:#953800;--doc-symbol-function-fg-color:#8250df;--doc-symbol-method-fg-color:#8250df;--doc-symbol-class-fg-color:#0550ae;--doc-symbol-type_alias-fg-color:#0550ae;--doc-symbol-module-fg-color:#5cad0f}[data-md-color-scheme=slate]{--doc-symbol-parameter-fg-color:#829bd1;--doc-symbol-type_parameter-fg-color:#829bd1;--doc-symbol-attribute-fg-color:#ffa657;--doc-symbol-function-fg-color:#d2a8ff;--doc-symbol-method-fg-color:#d2a8ff;--doc-symbol-class-fg-color:#79c0ff;--doc-symbol-type_alias-fg-color:#79c0ff;--doc-symbol-module-fg-color:#baff79}.md-ellipsis:has(.doc-symbol){font-family:var(--md-code-font-family);font-size:.95em}code.doc-symbol{background-color:initial;border-radius:.1rem;font-size:1em;font-weight:400}a code.doc-symbol-parameter,code.doc-symbol-parameter{color:var(--doc-symbol-parameter-fg-color)}.md-content code.doc-symbol-parameter:after{content:"param"}.md-sidebar code.doc-symbol-parameter:after{content:"p"}a code.doc-symbol-type_parameter,code.doc-symbol-type_parameter{color:var(--doc-symbol-type_parameter-fg-color)}.md-content code.doc-symbol-type_parameter:after{content:"type-param"}.md-sidebar code.doc-symbol-type_parameter:after{content:"t"}a code.doc-symbol-attribute,code.doc-symbol-attribute{color:var(--doc-symbol-attribute-fg-color)}.md-content code.doc-symbol-attribute:after{content:"attribute"}.md-sidebar code.doc-symbol-attribute:after{content:"a"}a code.doc-symbol-function,code.doc-symbol-function{color:var(--doc-symbol-function-fg-color)}.md-content code.doc-symbol-function:after{content:"function"}.md-sidebar code.doc-symbol-function:after{content:"f"}a code.doc-symbol-method,code.doc-symbol-method{color:var(--doc-symbol-method-fg-color)}.md-content code.doc-symbol-method:after{content:"method"}.md-sidebar code.doc-symbol-method:after{content:"m"}a code.doc-symbol-class,code.doc-symbol-class{color:var(--doc-symbol-class-fg-color)}.md-content code.doc-symbol-class:after{content:"class"}.md-sidebar code.doc-symbol-class:after{content:"c"}a code.doc-symbol-type_alias,code.doc-symbol-type_alias{color:var(--doc-symbol-type_alias-fg-color)}.md-content code.doc-symbol-type_alias:after{content:"type"}.md-sidebar code.doc-symbol-type_alias:after{content:"t"}a code.doc-symbol-module,code.doc-symbol-module{color:var(--doc-symbol-module-fg-color)}.md-content code.doc-symbol-module:after{content:"module"}.md-sidebar code.doc-symbol-module:after{content:"mod"}.md-typeset details.mkdocstrings-source{background:#0000;border:.05rem solid var(--md-code-bg-color)}.md-typeset details.mkdocstrings-source>summary:before{background-color:var(--md-default-fg-color--light);-webkit-mask-image:var(--md-admonition-icon--mkdocstrings);mask-image:var(--md-admonition-icon--mkdocstrings)}.md-typeset details.mkdocstrings-source[open]>summary:before{-webkit-mask-image:var(--md-admonition-icon--mkdocstrings-open);mask-image:var(--md-admonition-icon--mkdocstrings-open)}.md-typeset details.mkdocstrings-source>summary:after{background-color:var(--md-default-fg-color--light)}.md-typeset div.arithmatex{overflow:auto}@media screen and (max-width:44.984375em){.md-typeset div.arithmatex{margin:0 -.8rem}.md-typeset div.arithmatex>*{width:min-content}}.md-typeset div.arithmatex>*{margin-left:auto!important;margin-right:auto!important;padding:0 .8rem;touch-action:auto}.md-typeset div.arithmatex>* mjx-container{margin:0!important}.md-typeset div.arithmatex mjx-assistive-mml{height:0}.md-typeset del.critic{background-color:var(--md-typeset-del-color)}.md-typeset del.critic,.md-typeset ins.critic{-webkit-box-decoration-break:clone;box-decoration-break:clone}.md-typeset ins.critic{background-color:var(--md-typeset-ins-color)}.md-typeset .critic.comment{-webkit-box-decoration-break:clone;box-decoration-break:clone;color:var(--md-code-hl-comment-color)}.md-typeset .critic.comment:before{content:"/* "}.md-typeset .critic.comment:after{content:" */"}.md-typeset .critic.block{box-shadow:none;display:block;margin:1em 0;overflow:auto;padding-left:.8rem;padding-right:.8rem}.md-typeset .critic.block>:first-child{margin-top:.5em}.md-typeset .critic.block>:last-child{margin-bottom:.5em}:root{--md-details-icon:url('data:image/svg+xml;charset=utf-8,')}.md-typeset details{display:flow-root;overflow:visible;padding-top:0}.md-typeset details[open]>summary:after{transform:rotate(90deg)}.md-typeset details:not([open]){box-shadow:none;padding-bottom:0}.md-typeset details:not([open])>summary{border-radius:.1rem;margin-bottom:.6rem}[dir=ltr] .md-typeset summary{padding-right:1.6rem}[dir=rtl] .md-typeset summary{padding-left:1.6rem}[dir=ltr] .md-typeset summary{border-top-left-radius:.1rem}[dir=ltr] .md-typeset summary,[dir=rtl] .md-typeset summary{border-top-right-radius:.1rem}[dir=rtl] .md-typeset summary{border-top-left-radius:.1rem}.md-typeset summary{cursor:pointer;display:block;min-height:1rem;overflow:hidden}.md-typeset summary.focus-visible{outline-color:var(--md-accent-fg-color);outline-offset:.2rem}.md-typeset summary:not(.focus-visible){-webkit-tap-highlight-color:transparent;outline:none}[dir=ltr] .md-typeset summary:after{right:0}[dir=rtl] .md-typeset summary:after{left:0}.md-typeset summary:after{background-color:currentcolor;content:"";height:1rem;-webkit-mask-image:var(--md-details-icon);mask-image:var(--md-details-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.125em;transform:rotate(0deg);transition:transform .25s;width:1rem}[dir=rtl] .md-typeset summary:after{transform:rotate(180deg)}.md-typeset summary::marker{display:none}.md-typeset summary::-webkit-details-marker{display:none}.md-typeset .emojione,.md-typeset .gemoji,.md-typeset .twemoji{--md-icon-size:1.125em;display:inline-flex;height:var(--md-icon-size);vertical-align:text-top}.md-typeset .emojione svg,.md-typeset .gemoji svg,.md-typeset .twemoji svg{fill:currentcolor;max-height:100%;width:var(--md-icon-size)}.md-typeset .emojione svg.lucide,.md-typeset .gemoji svg.lucide,.md-typeset .twemoji svg.lucide{fill:#0000;stroke:currentcolor}.md-typeset .lg,.md-typeset .xl,.md-typeset .xxl,.md-typeset .xxxl{vertical-align:text-bottom}.md-typeset .middle{vertical-align:middle}.md-typeset .lg{--md-icon-size:1.5em}.md-typeset .xl{--md-icon-size:2.25em}.md-typeset .xxl{--md-icon-size:3em}.md-typeset .xxxl{--md-icon-size:4em}.highlight .o,.highlight .ow{color:var(--md-code-hl-operator-color)}.highlight .p{color:var(--md-code-hl-punctuation-color)}.highlight .cpf,.highlight .l,.highlight .s,.highlight .s1,.highlight .s2,.highlight .sb,.highlight .sc,.highlight .si,.highlight .ss{color:var(--md-code-hl-string-color)}.highlight .cp,.highlight .se,.highlight .sh,.highlight .sr,.highlight .sx{color:var(--md-code-hl-special-color)}.highlight .il,.highlight .m,.highlight .mb,.highlight .mf,.highlight .mh,.highlight .mi,.highlight .mo{color:var(--md-code-hl-number-color)}.highlight .k,.highlight .kd,.highlight .kn,.highlight .kp,.highlight .kr,.highlight .kt{color:var(--md-code-hl-keyword-color)}.highlight .kc,.highlight .n{color:var(--md-code-hl-name-color)}.highlight .bp,.highlight .nb,.highlight .no{color:var(--md-code-hl-constant-color)}.highlight .nc,.highlight .ne,.highlight .nf,.highlight .nn{color:var(--md-code-hl-function-color)}.highlight .nd,.highlight .ni,.highlight .nl,.highlight .nt{color:var(--md-code-hl-keyword-color)}.highlight .c,.highlight .c1,.highlight .ch,.highlight .cm,.highlight .cs,.highlight .sd{color:var(--md-code-hl-comment-color)}.highlight .na,.highlight .nv,.highlight .vc,.highlight .vg,.highlight .vi{color:var(--md-code-hl-variable-color)}.highlight .ge,.highlight .gh,.highlight .go,.highlight .gp,.highlight .gr,.highlight .gs,.highlight .gt,.highlight .gu{color:var(--md-code-hl-generic-color)}.highlight .gd,.highlight .gi{border-radius:.1rem;margin:0 -.125em;padding:0 .125em}.highlight .gd{background-color:var(--md-typeset-del-color)}.highlight .gi{background-color:var(--md-typeset-ins-color)}.highlight .hll{background-color:var(--md-code-hl-color--light);box-shadow:2px 0 0 0 var(--md-code-hl-color) inset;display:block;margin:0 -1.1764705882em;padding:0 1.1764705882em}.highlight span.filename{background-color:var(--md-code-bg-color);border-bottom:.05rem solid var(--md-default-fg-color--lightest);border-top-left-radius:.4rem;border-top-right-radius:.4rem;display:flow-root;font-size:.85em;font-weight:700;margin-top:1em;padding:.6617647059em 1.1764705882em;position:relative}.highlight span.filename+pre{margin-top:0}.highlight span.filename+pre>code{border-top-left-radius:0;border-top-right-radius:0}.highlight [data-linenos]:before{background-color:var(--md-code-bg-color);box-shadow:-.05rem 0 var(--md-default-fg-color--lightest) inset;color:var(--md-default-fg-color--light);content:attr(data-linenos);float:left;left:-1.1764705882em;margin-left:-1.1764705882em;margin-right:1.1764705882em;padding-left:1.1764705882em;position:sticky;-webkit-user-select:none;user-select:none;z-index:3}.highlight code>span[id^=__span]>:last-child .md-annotation{margin-right:2.4rem}.highlight code[data-md-copying]{display:initial}.highlight code[data-md-copying] .hll{display:contents}.highlight code[data-md-copying] .md-annotation{display:none}.highlighttable{display:flow-root}.highlighttable tbody,.highlighttable td{display:block;padding:0}.highlighttable tr{display:flex}.highlighttable pre{margin:0}.highlighttable th.filename{flex-grow:1;padding:0;text-align:left}.highlighttable th.filename span.filename{margin-top:0}.highlighttable .linenos{background-color:var(--md-code-bg-color);border-bottom-left-radius:.4rem;border-top-left-radius:.4rem;font-size:.85em;padding:.7720588235em 0 .7720588235em 1.1764705882em;-webkit-user-select:none;user-select:none}.highlighttable .linenodiv{box-shadow:-.05rem 0 var(--md-default-fg-color--lightest) inset}.highlighttable .linenodiv pre{color:var(--md-default-fg-color--light);text-align:right}.highlighttable .linenodiv span[class]{padding-right:.5882352941em}.highlighttable .code{flex:1;min-width:0}.linenodiv a{color:inherit;text-decoration:none}.md-typeset .highlighttable{direction:ltr;margin:1em 0}.md-typeset .highlighttable>tbody>tr>.code>div>pre>code{border-bottom-left-radius:0;border-top-left-radius:0}.md-typeset .highlight+.result{border:.05rem solid var(--md-code-bg-color);border-bottom-left-radius:.4rem;border-bottom-right-radius:.4rem;border-top-width:.4rem;margin-top:-1.5em;overflow:visible;padding:0 1em}.md-typeset .highlight+.result:after{clear:both;content:"";display:block}@media screen and (max-width:44.984375em){.md-content__inner>.highlight{margin:1em -.8rem}.md-content__inner>.highlight>.filename,.md-content__inner>.highlight>.highlighttable>tbody>tr>.code>div>pre>code,.md-content__inner>.highlight>.highlighttable>tbody>tr>.filename span.filename,.md-content__inner>.highlight>.highlighttable>tbody>tr>.linenos,.md-content__inner>.highlight>pre>code{border-radius:0}.md-content__inner>.highlight+.result{border-left-width:0;border-radius:0;border-right-width:0;margin-left:-.8rem;margin-right:-.8rem}}.md-typeset .keys kbd:after,.md-typeset .keys kbd:before{-moz-osx-font-smoothing:initial;-webkit-font-smoothing:initial;color:inherit;margin:0;position:relative}.md-typeset .keys span{color:var(--md-default-fg-color--light);padding:0 .2em}.md-typeset .keys .key-alt:before,.md-typeset .keys .key-left-alt:before,.md-typeset .keys .key-right-alt:before{content:"⎇";padding-right:.4em}.md-typeset .keys .key-command:before,.md-typeset .keys .key-left-command:before,.md-typeset .keys .key-right-command:before{content:"⌘";padding-right:.4em}.md-typeset .keys .key-control:before,.md-typeset .keys .key-left-control:before,.md-typeset .keys .key-right-control:before{content:"⌃";padding-right:.4em}.md-typeset .keys .key-left-meta:before,.md-typeset .keys .key-meta:before,.md-typeset .keys .key-right-meta:before{content:"◆";padding-right:.4em}.md-typeset .keys .key-left-option:before,.md-typeset .keys .key-option:before,.md-typeset .keys .key-right-option:before{content:"⌥";padding-right:.4em}.md-typeset .keys .key-left-shift:before,.md-typeset .keys .key-right-shift:before,.md-typeset .keys .key-shift:before{content:"⇧";padding-right:.4em}.md-typeset .keys .key-left-super:before,.md-typeset .keys .key-right-super:before,.md-typeset .keys .key-super:before{content:"❖";padding-right:.4em}.md-typeset .keys .key-left-windows:before,.md-typeset .keys .key-right-windows:before,.md-typeset .keys .key-windows:before{content:"⊞";padding-right:.4em}.md-typeset .keys .key-arrow-down:before{content:"↓";padding-right:.4em}.md-typeset .keys .key-arrow-left:before{content:"←";padding-right:.4em}.md-typeset .keys .key-arrow-right:before{content:"→";padding-right:.4em}.md-typeset .keys .key-arrow-up:before{content:"↑";padding-right:.4em}.md-typeset .keys .key-backspace:before{content:"⌫";padding-right:.4em}.md-typeset .keys .key-backtab:before{content:"⇤";padding-right:.4em}.md-typeset .keys .key-caps-lock:before{content:"⇪";padding-right:.4em}.md-typeset .keys .key-clear:before{content:"⌧";padding-right:.4em}.md-typeset .keys .key-context-menu:before{content:"☰";padding-right:.4em}.md-typeset .keys .key-delete:before{content:"⌦";padding-right:.4em}.md-typeset .keys .key-eject:before{content:"⏏";padding-right:.4em}.md-typeset .keys .key-end:before{content:"⤓";padding-right:.4em}.md-typeset .keys .key-escape:before{content:"⎋";padding-right:.4em}.md-typeset .keys .key-home:before{content:"⤒";padding-right:.4em}.md-typeset .keys .key-insert:before{content:"⎀";padding-right:.4em}.md-typeset .keys .key-page-down:before{content:"⇟";padding-right:.4em}.md-typeset .keys .key-page-up:before{content:"⇞";padding-right:.4em}.md-typeset .keys .key-print-screen:before{content:"⎙";padding-right:.4em}.md-typeset .keys .key-tab:after{content:"⇥";padding-left:.4em}.md-typeset .keys .key-num-enter:after{content:"⌤";padding-left:.4em}.md-typeset .keys .key-enter:after{content:"⏎";padding-left:.4em}:root{--md-tabbed-icon--prev:url('data:image/svg+xml;charset=utf-8,');--md-tabbed-icon--next:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .tabbed-set{border-radius:.075rem;display:flex;flex-flow:column wrap;margin:1em 0;position:relative}.md-typeset .tabbed-set>input{height:0;opacity:0;position:absolute;width:0}.md-typeset .tabbed-set>input:target{--md-scroll-offset:0.625em}.md-typeset .tabbed-set>input.focus-visible~.tabbed-labels:before{background-color:var(--md-accent-fg-color)}.md-typeset .tabbed-labels{-ms-overflow-style:none;box-shadow:0 -.05rem var(--md-default-fg-color--lightest) inset;display:flex;max-width:100%;overflow:auto;scrollbar-width:none}@media print{.md-typeset .tabbed-labels{display:contents}}@media screen{.js .md-typeset .tabbed-labels{position:relative}.js .md-typeset .tabbed-labels:before{background:var(--md-default-fg-color);bottom:0;content:"";display:block;height:1.5px;left:0;position:absolute;transform:translateX(var(--md-indicator-x));transition:width 225ms,background-color .25s,transform .25s;transition-timing-function:cubic-bezier(.4,0,.2,1);width:var(--md-indicator-width)}}.md-typeset .tabbed-labels::-webkit-scrollbar{display:none}.md-typeset .tabbed-labels>label{border-bottom:.1rem solid #0000;border-radius:.1rem .1rem 0 0;color:var(--md-default-fg-color--light);cursor:pointer;flex-shrink:0;font-size:.7rem;font-weight:400;padding:.78125em 1.25em .625em;scroll-margin-inline-start:1rem;transition:background-color .25s,color .25s;white-space:nowrap;width:auto}@media print{.md-typeset .tabbed-labels>label:first-child{order:1}.md-typeset .tabbed-labels>label:nth-child(2){order:2}.md-typeset .tabbed-labels>label:nth-child(3){order:3}.md-typeset .tabbed-labels>label:nth-child(4){order:4}.md-typeset .tabbed-labels>label:nth-child(5){order:5}.md-typeset .tabbed-labels>label:nth-child(6){order:6}.md-typeset .tabbed-labels>label:nth-child(7){order:7}.md-typeset .tabbed-labels>label:nth-child(8){order:8}.md-typeset .tabbed-labels>label:nth-child(9){order:9}.md-typeset .tabbed-labels>label:nth-child(10){order:10}.md-typeset .tabbed-labels>label:nth-child(11){order:11}.md-typeset .tabbed-labels>label:nth-child(12){order:12}.md-typeset .tabbed-labels>label:nth-child(13){order:13}.md-typeset .tabbed-labels>label:nth-child(14){order:14}.md-typeset .tabbed-labels>label:nth-child(15){order:15}.md-typeset .tabbed-labels>label:nth-child(16){order:16}.md-typeset .tabbed-labels>label:nth-child(17){order:17}.md-typeset .tabbed-labels>label:nth-child(18){order:18}.md-typeset .tabbed-labels>label:nth-child(19){order:19}.md-typeset .tabbed-labels>label:nth-child(20){order:20}}.md-typeset .tabbed-labels>label:hover{color:var(--md-default-fg-color)}.md-typeset .tabbed-labels>label>[href]:first-child{color:inherit;text-decoration:none}.md-typeset .tabbed-labels--linked>label{padding:0}.md-typeset .tabbed-labels--linked>label>a{display:block;padding:.78125em 1.25em .625em}.md-typeset .tabbed-content{width:100%}@media print{.md-typeset .tabbed-content{display:contents}}.md-typeset .tabbed-block{display:none}@media print{.md-typeset .tabbed-block{display:block}.md-typeset .tabbed-block:first-child{order:1}.md-typeset .tabbed-block:nth-child(2){order:2}.md-typeset .tabbed-block:nth-child(3){order:3}.md-typeset .tabbed-block:nth-child(4){order:4}.md-typeset .tabbed-block:nth-child(5){order:5}.md-typeset .tabbed-block:nth-child(6){order:6}.md-typeset .tabbed-block:nth-child(7){order:7}.md-typeset .tabbed-block:nth-child(8){order:8}.md-typeset .tabbed-block:nth-child(9){order:9}.md-typeset .tabbed-block:nth-child(10){order:10}.md-typeset .tabbed-block:nth-child(11){order:11}.md-typeset .tabbed-block:nth-child(12){order:12}.md-typeset .tabbed-block:nth-child(13){order:13}.md-typeset .tabbed-block:nth-child(14){order:14}.md-typeset .tabbed-block:nth-child(15){order:15}.md-typeset .tabbed-block:nth-child(16){order:16}.md-typeset .tabbed-block:nth-child(17){order:17}.md-typeset .tabbed-block:nth-child(18){order:18}.md-typeset .tabbed-block:nth-child(19){order:19}.md-typeset .tabbed-block:nth-child(20){order:20}}.md-typeset .tabbed-block>.highlight:first-child>pre,.md-typeset .tabbed-block>pre:first-child{margin:0}.md-typeset .tabbed-block>.highlight:first-child>pre>code,.md-typeset .tabbed-block>pre:first-child>code{border-top-left-radius:0;border-top-right-radius:0}.md-typeset .tabbed-block>.highlight:first-child>.filename{border-top-left-radius:0;border-top-right-radius:0;margin:0}.md-typeset .tabbed-block>.highlight:first-child>.highlighttable{margin:0}.md-typeset .tabbed-block>.highlight:first-child>.highlighttable>tbody>tr>.filename span.filename,.md-typeset .tabbed-block>.highlight:first-child>.highlighttable>tbody>tr>.linenos{border-top-left-radius:0;border-top-right-radius:0;margin:0}.md-typeset .tabbed-block>.highlight:first-child>.highlighttable>tbody>tr>.code>div>pre>code{border-top-left-radius:0;border-top-right-radius:0}.md-typeset .tabbed-block>.highlight:first-child+.result{margin-top:-.125em}.md-typeset .tabbed-block>.tabbed-set{margin:0}.md-typeset .tabbed-button{align-self:center;-webkit-backdrop-filter:blur(.4rem);backdrop-filter:blur(.4rem);background-color:var(--md-default-bg-color--light);border-radius:100%;box-shadow:var(--md-shadow-z2);color:var(--md-default-fg-color--light);cursor:pointer;display:block;height:.9rem;margin-top:.4rem;pointer-events:auto;transition:transform 125ms;width:.9rem}.md-typeset .tabbed-button:hover{transform:scale(1.125)}.md-typeset .tabbed-button:after{background-color:currentcolor;content:"";display:block;height:100%;-webkit-mask-image:var(--md-tabbed-icon--prev);mask-image:var(--md-tabbed-icon--prev);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;transition:background-color .25s,transform .25s;width:100%}.md-typeset .tabbed-control{display:flex;height:1.9rem;justify-content:start;pointer-events:none;position:absolute;transition:opacity 125ms;width:1.2rem}[dir=rtl] .md-typeset .tabbed-control{transform:rotate(180deg)}.md-typeset .tabbed-control[hidden]{opacity:0}.md-typeset .tabbed-control--next{justify-content:end;right:0}.md-typeset .tabbed-control--next .tabbed-button:after{-webkit-mask-image:var(--md-tabbed-icon--next);mask-image:var(--md-tabbed-icon--next)}@media screen and (max-width:44.984375em){[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels{padding-left:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels{padding-right:.8rem}.md-content__inner>.tabbed-set .tabbed-labels{margin:0 -.8rem;max-width:100vw;scroll-padding-inline-start:.8rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels:after{padding-right:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels:after{padding-left:.8rem}.md-content__inner>.tabbed-set .tabbed-labels:after{content:""}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{padding-left:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{padding-right:.8rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{margin-left:-.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{margin-right:-.8rem}.md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--prev{width:2rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{padding-right:.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{padding-left:.8rem}[dir=ltr] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{margin-right:-.8rem}[dir=rtl] .md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{margin-left:-.8rem}.md-content__inner>.tabbed-set .tabbed-labels~.tabbed-control--next{width:2rem}}@media screen{.md-typeset .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9){color:var(--md-default-fg-color);font-weight:500}.md-typeset .no-js .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.md-typeset .no-js .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.md-typeset .no-js .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.md-typeset .no-js .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.md-typeset .no-js .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.md-typeset .no-js .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.md-typeset .no-js .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.md-typeset .no-js .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.md-typeset .no-js .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.md-typeset .no-js .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.md-typeset .no-js .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.md-typeset .no-js .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.md-typeset .no-js .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.md-typeset .no-js .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.md-typeset .no-js .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.md-typeset .no-js .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.md-typeset .no-js .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.md-typeset .no-js .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.md-typeset .no-js .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.md-typeset .no-js .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),.md-typeset [role=dialog] .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.md-typeset [role=dialog] .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.md-typeset [role=dialog] .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.md-typeset [role=dialog] .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.md-typeset [role=dialog] .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.md-typeset [role=dialog] .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.md-typeset [role=dialog] .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.md-typeset [role=dialog] .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.md-typeset [role=dialog] .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.md-typeset [role=dialog] .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.md-typeset [role=dialog] .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.md-typeset [role=dialog] .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.md-typeset [role=dialog] .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.md-typeset [role=dialog] .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.md-typeset [role=dialog] .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.md-typeset [role=dialog] .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.md-typeset [role=dialog] .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.md-typeset [role=dialog] .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.md-typeset [role=dialog] .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.md-typeset [role=dialog] .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),.no-js .md-typeset .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,.no-js .md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),.no-js .md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),.no-js .md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),.no-js .md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),.no-js .md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),.no-js .md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),.no-js .md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),.no-js .md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),.no-js .md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),.no-js .md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),.no-js .md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),.no-js .md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),.no-js .md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),.no-js .md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),.no-js .md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),.no-js .md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),.no-js .md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),.no-js .md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),.no-js .md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9),[role=dialog] .md-typeset .tabbed-set>input:first-child:checked~.tabbed-labels>:first-child,[role=dialog] .md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-labels>:nth-child(10),[role=dialog] .md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-labels>:nth-child(11),[role=dialog] .md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-labels>:nth-child(12),[role=dialog] .md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-labels>:nth-child(13),[role=dialog] .md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-labels>:nth-child(14),[role=dialog] .md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-labels>:nth-child(15),[role=dialog] .md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-labels>:nth-child(16),[role=dialog] .md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-labels>:nth-child(17),[role=dialog] .md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-labels>:nth-child(18),[role=dialog] .md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-labels>:nth-child(19),[role=dialog] .md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-labels>:nth-child(2),[role=dialog] .md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-labels>:nth-child(20),[role=dialog] .md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-labels>:nth-child(3),[role=dialog] .md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-labels>:nth-child(4),[role=dialog] .md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-labels>:nth-child(5),[role=dialog] .md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-labels>:nth-child(6),[role=dialog] .md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-labels>:nth-child(7),[role=dialog] .md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-labels>:nth-child(8),[role=dialog] .md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-labels>:nth-child(9){border-color:var(--md-default-fg-color)}}.md-typeset .tabbed-set>input:first-child.focus-visible~.tabbed-labels>:first-child,.md-typeset .tabbed-set>input:nth-child(10).focus-visible~.tabbed-labels>:nth-child(10),.md-typeset .tabbed-set>input:nth-child(11).focus-visible~.tabbed-labels>:nth-child(11),.md-typeset .tabbed-set>input:nth-child(12).focus-visible~.tabbed-labels>:nth-child(12),.md-typeset .tabbed-set>input:nth-child(13).focus-visible~.tabbed-labels>:nth-child(13),.md-typeset .tabbed-set>input:nth-child(14).focus-visible~.tabbed-labels>:nth-child(14),.md-typeset .tabbed-set>input:nth-child(15).focus-visible~.tabbed-labels>:nth-child(15),.md-typeset .tabbed-set>input:nth-child(16).focus-visible~.tabbed-labels>:nth-child(16),.md-typeset .tabbed-set>input:nth-child(17).focus-visible~.tabbed-labels>:nth-child(17),.md-typeset .tabbed-set>input:nth-child(18).focus-visible~.tabbed-labels>:nth-child(18),.md-typeset .tabbed-set>input:nth-child(19).focus-visible~.tabbed-labels>:nth-child(19),.md-typeset .tabbed-set>input:nth-child(2).focus-visible~.tabbed-labels>:nth-child(2),.md-typeset .tabbed-set>input:nth-child(20).focus-visible~.tabbed-labels>:nth-child(20),.md-typeset .tabbed-set>input:nth-child(3).focus-visible~.tabbed-labels>:nth-child(3),.md-typeset .tabbed-set>input:nth-child(4).focus-visible~.tabbed-labels>:nth-child(4),.md-typeset .tabbed-set>input:nth-child(5).focus-visible~.tabbed-labels>:nth-child(5),.md-typeset .tabbed-set>input:nth-child(6).focus-visible~.tabbed-labels>:nth-child(6),.md-typeset .tabbed-set>input:nth-child(7).focus-visible~.tabbed-labels>:nth-child(7),.md-typeset .tabbed-set>input:nth-child(8).focus-visible~.tabbed-labels>:nth-child(8),.md-typeset .tabbed-set>input:nth-child(9).focus-visible~.tabbed-labels>:nth-child(9){color:var(--md-accent-fg-color)}.md-typeset .tabbed-set>input:first-child:checked~.tabbed-content>:first-child,.md-typeset .tabbed-set>input:nth-child(10):checked~.tabbed-content>:nth-child(10),.md-typeset .tabbed-set>input:nth-child(11):checked~.tabbed-content>:nth-child(11),.md-typeset .tabbed-set>input:nth-child(12):checked~.tabbed-content>:nth-child(12),.md-typeset .tabbed-set>input:nth-child(13):checked~.tabbed-content>:nth-child(13),.md-typeset .tabbed-set>input:nth-child(14):checked~.tabbed-content>:nth-child(14),.md-typeset .tabbed-set>input:nth-child(15):checked~.tabbed-content>:nth-child(15),.md-typeset .tabbed-set>input:nth-child(16):checked~.tabbed-content>:nth-child(16),.md-typeset .tabbed-set>input:nth-child(17):checked~.tabbed-content>:nth-child(17),.md-typeset .tabbed-set>input:nth-child(18):checked~.tabbed-content>:nth-child(18),.md-typeset .tabbed-set>input:nth-child(19):checked~.tabbed-content>:nth-child(19),.md-typeset .tabbed-set>input:nth-child(2):checked~.tabbed-content>:nth-child(2),.md-typeset .tabbed-set>input:nth-child(20):checked~.tabbed-content>:nth-child(20),.md-typeset .tabbed-set>input:nth-child(3):checked~.tabbed-content>:nth-child(3),.md-typeset .tabbed-set>input:nth-child(4):checked~.tabbed-content>:nth-child(4),.md-typeset .tabbed-set>input:nth-child(5):checked~.tabbed-content>:nth-child(5),.md-typeset .tabbed-set>input:nth-child(6):checked~.tabbed-content>:nth-child(6),.md-typeset .tabbed-set>input:nth-child(7):checked~.tabbed-content>:nth-child(7),.md-typeset .tabbed-set>input:nth-child(8):checked~.tabbed-content>:nth-child(8),.md-typeset .tabbed-set>input:nth-child(9):checked~.tabbed-content>:nth-child(9){display:block}:root{--md-tasklist-icon:url('data:image/svg+xml;charset=utf-8,');--md-tasklist-icon--checked:url('data:image/svg+xml;charset=utf-8,')}.md-typeset .task-list-item{list-style-type:none;position:relative}[dir=ltr] .md-typeset .task-list-item [type=checkbox]{left:-2em}[dir=rtl] .md-typeset .task-list-item [type=checkbox]{right:-2em}.md-typeset .task-list-item [type=checkbox]{position:absolute;top:.45em}.md-typeset .task-list-control [type=checkbox]{opacity:0;z-index:-1}[dir=ltr] .md-typeset .task-list-indicator:before{left:-1.5em}[dir=rtl] .md-typeset .task-list-indicator:before{right:-1.5em}.md-typeset .task-list-indicator:before{background-color:var(--md-default-fg-color--lighter);content:"";height:1.25em;-webkit-mask-image:var(--md-tasklist-icon);mask-image:var(--md-tasklist-icon);-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:contain;mask-size:contain;position:absolute;top:.25em;width:1.25em}.md-typeset [type=checkbox]:checked+.task-list-indicator:before{background-color:#00e676;-webkit-mask-image:var(--md-tasklist-icon--checked);mask-image:var(--md-tasklist-icon--checked)}@media print{.giscus,[id=__comments]{display:none}}:root>*{--md-mermaid-font-family:var(--md-text-font-family),sans-serif;--md-mermaid-edge-color:var(--md-code-fg-color);--md-mermaid-node-bg-color:var(--md-accent-fg-color--transparent);--md-mermaid-node-fg-color:var(--md-accent-fg-color);--md-mermaid-label-bg-color:var(--md-default-bg-color);--md-mermaid-label-fg-color:var(--md-code-fg-color);--md-mermaid-sequence-actor-bg-color:var(--md-mermaid-label-bg-color);--md-mermaid-sequence-actor-fg-color:var(--md-mermaid-label-fg-color);--md-mermaid-sequence-actor-border-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-actor-line-color:var(--md-default-fg-color--lighter);--md-mermaid-sequence-actorman-bg-color:var(--md-mermaid-label-bg-color);--md-mermaid-sequence-actorman-line-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-box-bg-color:var(--md-mermaid-node-bg-color);--md-mermaid-sequence-box-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-label-bg-color:var(--md-mermaid-node-bg-color);--md-mermaid-sequence-label-fg-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-loop-bg-color:var(--md-mermaid-node-bg-color);--md-mermaid-sequence-loop-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-loop-border-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-message-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-message-line-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-note-bg-color:var(--md-mermaid-label-bg-color);--md-mermaid-sequence-note-fg-color:var(--md-mermaid-edge-color);--md-mermaid-sequence-note-border-color:var(--md-mermaid-label-fg-color);--md-mermaid-sequence-number-bg-color:var(--md-mermaid-node-fg-color);--md-mermaid-sequence-number-fg-color:var(--md-accent-bg-color)}.mermaid{line-height:normal;margin:1em 0}.md-typeset .grid{grid-gap:.4rem;display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr));margin:1em 0}.md-typeset .grid.cards>ol,.md-typeset .grid.cards>ul{display:contents}.md-typeset .grid.cards>ol>li,.md-typeset .grid.cards>ul>li,.md-typeset .grid>.card{border:.05rem solid var(--md-default-fg-color--lightest);border-radius:.4rem;display:block;margin:0;padding:.8rem;transition:background-color .25s,border .25s,box-shadow .25s}.md-typeset .grid.cards>ol>li:focus-within,.md-typeset .grid.cards>ol>li:hover,.md-typeset .grid.cards>ul>li:focus-within,.md-typeset .grid.cards>ul>li:hover,.md-typeset .grid>.card:focus-within,.md-typeset .grid>.card:hover{border-color:#0000;box-shadow:var(--md-shadow-z2)}.md-typeset .grid.cards>ol>li>hr,.md-typeset .grid.cards>ul>li>hr,.md-typeset .grid>.card>hr{margin-bottom:1em;margin-top:1em}.md-typeset .grid.cards>ol>li>:first-child,.md-typeset .grid.cards>ul>li>:first-child,.md-typeset .grid>.card>:first-child{margin-top:0}.md-typeset .grid.cards>ol>li>:last-child,.md-typeset .grid.cards>ul>li>:last-child,.md-typeset .grid>.card>:last-child{margin-bottom:0}.md-typeset .grid>*,.md-typeset .grid>.admonition,.md-typeset .grid>.highlight>*,.md-typeset .grid>.highlighttable,.md-typeset .grid>.md-typeset details,.md-typeset .grid>details,.md-typeset .grid>pre{margin-bottom:0;margin-top:0}.md-typeset .grid>.highlight>pre:only-child,.md-typeset .grid>.highlight>pre>code,.md-typeset .grid>.highlighttable,.md-typeset .grid>.highlighttable>tbody,.md-typeset .grid>.highlighttable>tbody>tr,.md-typeset .grid>.highlighttable>tbody>tr>.code,.md-typeset .grid>.highlighttable>tbody>tr>.code>.highlight,.md-typeset .grid>.highlighttable>tbody>tr>.code>.highlight>pre,.md-typeset .grid>.highlighttable>tbody>tr>.code>.highlight>pre>code{height:100%}.md-typeset .grid>.tabbed-set{margin-bottom:0;margin-top:0}@media screen and (min-width:45em){[dir=ltr] .md-typeset .inline{float:left}[dir=rtl] .md-typeset .inline{float:right}[dir=ltr] .md-typeset .inline{margin-right:.8rem}[dir=rtl] .md-typeset .inline{margin-left:.8rem}.md-typeset .inline{margin-bottom:.8rem;margin-top:0;width:11.7rem}[dir=ltr] .md-typeset .inline.end{float:right}[dir=rtl] .md-typeset .inline.end{float:left}[dir=ltr] .md-typeset .inline.end{margin-left:.8rem;margin-right:0}[dir=rtl] .md-typeset .inline.end{margin-left:0;margin-right:.8rem}} \ No newline at end of file diff --git a/docs/site/assets/stylesheets/modern/palette.dfe2e883.min.css b/docs/site/assets/stylesheets/modern/palette.dfe2e883.min.css new file mode 100644 index 0000000..d58a561 --- /dev/null +++ b/docs/site/assets/stylesheets/modern/palette.dfe2e883.min.css @@ -0,0 +1 @@ +@media screen{[data-md-color-scheme=slate]{--md-default-fg-color:hsla(var(--md-hue),15%,90%,0.82);--md-default-fg-color--light:hsla(var(--md-hue),15%,90%,0.56);--md-default-fg-color--lighter:hsla(var(--md-hue),15%,90%,0.32);--md-default-fg-color--lightest:hsla(var(--md-hue),15%,90%,0.12);--md-default-bg-color:hsla(var(--md-hue),15%,5%,1);--md-default-bg-color--light:hsla(var(--md-hue),15%,5%,0.54);--md-default-bg-color--lighter:hsla(var(--md-hue),15%,5%,0.26);--md-default-bg-color--lightest:hsla(var(--md-hue),15%,5%,0.07);--md-code-fg-color:hsla(var(--md-hue),20%,80%,1);--md-code-bg-color:hsla(var(--md-hue),20%,10%,1);--md-code-bg-color--light:hsla(var(--md-hue),20%,10%,0.9);--md-code-bg-color--lighter:hsla(var(--md-hue),20%,10%,0.54);--md-code-hl-color:#2977ff;--md-code-hl-color--light:#2977ff1a;--md-code-hl-number-color:#e6695b;--md-code-hl-special-color:#f06090;--md-code-hl-function-color:#c973d9;--md-code-hl-constant-color:#9383e2;--md-code-hl-keyword-color:#6791e0;--md-code-hl-string-color:#2fb170;--md-code-hl-name-color:var(--md-code-fg-color);--md-code-hl-operator-color:var(--md-default-fg-color--light);--md-code-hl-punctuation-color:var(--md-default-fg-color--light);--md-code-hl-comment-color:var(--md-default-fg-color--light);--md-code-hl-generic-color:var(--md-default-fg-color--light);--md-code-hl-variable-color:var(--md-default-fg-color--light);--md-typeset-color:var(--md-default-fg-color);--md-typeset-a-color:var(--md-primary-fg-color);--md-typeset-kbd-color:hsla(var(--md-hue),15%,90%,0.12);--md-typeset-kbd-accent-color:hsla(var(--md-hue),15%,90%,0.2);--md-typeset-kbd-border-color:hsla(var(--md-hue),15%,14%,1);--md-typeset-mark-color:#4287ff4d;--md-typeset-table-color:hsla(var(--md-hue),15%,95%,0.12);--md-typeset-table-color--light:hsla(var(--md-hue),15%,95%,0.035);--md-admonition-fg-color:var(--md-default-fg-color);--md-admonition-bg-color:var(--md-default-bg-color);--md-footer-bg-color:hsla(var(--md-hue),15%,10%,0.87);--md-footer-bg-color--dark:hsla(var(--md-hue),15%,8%,1);--md-shadow-z1:0 0.2rem 0.5rem #0000000d,0 0 0.05rem #ffffff1a;--md-shadow-z2:0 0.2rem 0.5rem #00000040,0 0 0.05rem #ffffff59;--md-shadow-z3:0 0.5rem 2rem #0006,0 0 0.05rem #00000059;color-scheme:dark}[data-md-color-scheme=slate] .md-header__title,[data-md-color-scheme=slate] h1,[data-md-color-scheme=slate] h2,[data-md-color-scheme=slate] h3,[data-md-color-scheme=slate] h4,[data-md-color-scheme=slate] h5,[data-md-color-scheme=slate] h6{color:hsla(var(--md-hue),0%,100%,1)}[data-md-color-scheme=slate] img[src$="#gh-light-mode-only"],[data-md-color-scheme=slate] img[src$="#only-light"]{display:none}[data-md-color-scheme=slate]{--color-foreground:255 255 255;--color-background:22 23 26;--color-background-subtle:33 34 38;--color-backdrop:11 12 15}[data-md-color-scheme=slate][data-md-color-primary=pink]{--md-typeset-a-color:#ed5487}[data-md-color-scheme=slate][data-md-color-primary=purple]{--md-typeset-a-color:#c46fd3}[data-md-color-scheme=slate][data-md-color-primary=deep-purple]{--md-typeset-a-color:#a47bea}[data-md-color-scheme=slate][data-md-color-primary=indigo]{--md-typeset-a-color:#5488e8}[data-md-color-scheme=slate][data-md-color-primary=teal]{--md-typeset-a-color:#00ccb8}[data-md-color-scheme=slate][data-md-color-primary=green]{--md-typeset-a-color:#71c174}[data-md-color-scheme=slate][data-md-color-primary=deep-orange]{--md-typeset-a-color:#ff764d}[data-md-color-scheme=slate][data-md-color-primary=brown]{--md-typeset-a-color:#c1775c}[data-md-color-scheme=slate][data-md-color-primary=black],[data-md-color-scheme=slate][data-md-color-primary=blue-grey],[data-md-color-scheme=slate][data-md-color-primary=grey],[data-md-color-scheme=slate][data-md-color-primary=white]{--md-typeset-a-color:#5e8bde}[data-md-color-switching] *,[data-md-color-switching] :after,[data-md-color-switching] :before{transition-duration:0ms!important}}[data-md-color-accent=red]{--md-accent-fg-color:#ff1947;--md-accent-fg-color--transparent:#ff19471a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=pink]{--md-accent-fg-color:#f50056;--md-accent-fg-color--transparent:#f500561a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=purple]{--md-accent-fg-color:#df41fb;--md-accent-fg-color--transparent:#df41fb1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=deep-purple]{--md-accent-fg-color:#7c4dff;--md-accent-fg-color--transparent:#7c4dff1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=indigo]{--md-accent-fg-color:#526cfe;--md-accent-fg-color--transparent:#526cfe1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=blue]{--md-accent-fg-color:#4287ff;--md-accent-fg-color--transparent:#4287ff1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=light-blue]{--md-accent-fg-color:#0091eb;--md-accent-fg-color--transparent:#0091eb1a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=cyan]{--md-accent-fg-color:#00bad6;--md-accent-fg-color--transparent:#00bad61a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=teal]{--md-accent-fg-color:#00bda4;--md-accent-fg-color--transparent:#00bda41a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=green]{--md-accent-fg-color:#00c753;--md-accent-fg-color--transparent:#00c7531a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=light-green]{--md-accent-fg-color:#63de17;--md-accent-fg-color--transparent:#63de171a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-accent=lime]{--md-accent-fg-color:#b0eb00;--md-accent-fg-color--transparent:#b0eb001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=yellow]{--md-accent-fg-color:#ffd500;--md-accent-fg-color--transparent:#ffd5001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=amber]{--md-accent-fg-color:#fa0;--md-accent-fg-color--transparent:#ffaa001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=orange]{--md-accent-fg-color:#ff9100;--md-accent-fg-color--transparent:#ff91001a;--md-accent-bg-color:#000000de;--md-accent-bg-color--light:#0000008a}[data-md-color-accent=deep-orange]{--md-accent-fg-color:#ff6e42;--md-accent-fg-color--transparent:#ff6e421a;--md-accent-bg-color:#fff;--md-accent-bg-color--light:#ffffffb3}[data-md-color-primary=red]{--md-primary-fg-color:#ef5552;--md-primary-fg-color--light:#e57171;--md-primary-fg-color--dark:#e53734;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=pink]{--md-primary-fg-color:#e92063;--md-primary-fg-color--light:#ec417a;--md-primary-fg-color--dark:#c3185d;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=purple]{--md-primary-fg-color:#ab47bd;--md-primary-fg-color--light:#bb69c9;--md-primary-fg-color--dark:#8c24a8;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=deep-purple]{--md-primary-fg-color:#7e56c2;--md-primary-fg-color--light:#9574cd;--md-primary-fg-color--dark:#673ab6;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=indigo]{--md-primary-fg-color:#4051b5;--md-primary-fg-color--light:#5d6cc0;--md-primary-fg-color--dark:#303fa1;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=blue]{--md-primary-fg-color:#2094f3;--md-primary-fg-color--light:#42a5f5;--md-primary-fg-color--dark:#1975d2;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=light-blue]{--md-primary-fg-color:#02a6f2;--md-primary-fg-color--light:#28b5f6;--md-primary-fg-color--dark:#0287cf;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=cyan]{--md-primary-fg-color:#00bdd6;--md-primary-fg-color--light:#25c5da;--md-primary-fg-color--dark:#0097a8;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=teal]{--md-primary-fg-color:#009485;--md-primary-fg-color--light:#26a699;--md-primary-fg-color--dark:#007a6c;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=green]{--md-primary-fg-color:#4cae4f;--md-primary-fg-color--light:#68bb6c;--md-primary-fg-color--dark:#398e3d;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=light-green]{--md-primary-fg-color:#8bc34b;--md-primary-fg-color--light:#9ccc66;--md-primary-fg-color--dark:#689f38;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=lime]{--md-primary-fg-color:#cbdc38;--md-primary-fg-color--light:#d3e156;--md-primary-fg-color--dark:#b0b52c;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=yellow]{--md-primary-fg-color:#ffec3d;--md-primary-fg-color--light:#ffee57;--md-primary-fg-color--dark:#fbc02d;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=amber]{--md-primary-fg-color:#ffc105;--md-primary-fg-color--light:#ffc929;--md-primary-fg-color--dark:#ffa200;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=orange]{--md-primary-fg-color:#ffa724;--md-primary-fg-color--light:#ffa724;--md-primary-fg-color--dark:#fa8900;--md-primary-bg-color:#000000de;--md-primary-bg-color--light:#0000008a}[data-md-color-primary=deep-orange]{--md-primary-fg-color:#ff6e42;--md-primary-fg-color--light:#ff8a66;--md-primary-fg-color--dark:#f4511f;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=brown]{--md-primary-fg-color:#795649;--md-primary-fg-color--light:#8d6e62;--md-primary-fg-color--dark:#5d4037;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3}[data-md-color-primary=grey]{--md-primary-fg-color:#757575;--md-primary-fg-color--light:#9e9e9e;--md-primary-fg-color--dark:#616161;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3;--md-typeset-a-color:#4051b5}[data-md-color-primary=blue-grey]{--md-primary-fg-color:#546d78;--md-primary-fg-color--light:#607c8a;--md-primary-fg-color--dark:#455a63;--md-primary-bg-color:#fff;--md-primary-bg-color--light:#ffffffb3;--md-typeset-a-color:#4051b5}[data-md-color-primary=light-green]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#72ad2e}[data-md-color-primary=lime]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#8b990a}[data-md-color-primary=yellow]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#b8a500}[data-md-color-primary=amber]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#d19d00}[data-md-color-primary=orange]:not([data-md-color-scheme=slate]){--md-typeset-a-color:#e68a00} \ No newline at end of file diff --git a/docs/site/components/discovery/index.html b/docs/site/components/discovery/index.html new file mode 100644 index 0000000..0ffdc5b --- /dev/null +++ b/docs/site/components/discovery/index.html @@ -0,0 +1,2124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Discovery - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + + + +

Discovery

+
+

Source: cortex.discovery.daemon, +cortex.discovery.client, +cortex.discovery.protocol

+
+

Discovery is Cortex's control plane: a single long-lived process that maps +topic names to ZMQ endpoints. It sits off the data path — once a subscriber +has an endpoint, messages flow publisher → subscriber directly without the +daemon's involvement.

+

Moving parts

+
flowchart LR
+    subgraph DP[discovery package]
+        PR[protocol.py<br/>DiscoveryRequest /<br/>DiscoveryResponse /<br/>TopicInfo]
+        DM[daemon.py<br/>DiscoveryDaemon<br/>ZMQ REP loop]
+        CL[client.py<br/>DiscoveryClient<br/>ZMQ REQ wrapper]
+    end
+
+    CL -- msgpack REQ --> DM
+    DM -- msgpack REP --> CL
+    PR -.-> DM
+    PR -.-> CL
+

Everyone agrees on the wire format via protocol.py. The daemon runs a +single-threaded REP loop. The client speaks REQ from every publisher and +subscriber in the graph.

+

Daemon

+

Implemented in [DiscoveryDaemon][cortex.discovery.daemon.DiscoveryDaemon].

+

Key behaviors:

+
    +
  • Binds zmq.REP at ipc:///tmp/cortex/discovery.sock by default.
  • +
  • Maintains _topics: dict[str, TopicInfo]one publisher per topic.
  • +
  • RCVTIMEO=1000 on the socket so the loop can check _running for clean + Ctrl-C. This also means the daemon is naturally single-request-at-a-time — + a slow client blocks all others.
  • +
+

State transitions

+
stateDiagram-v2
+    [*] --> Starting
+    Starting --> Running: bind OK
+    Running --> Running: REGISTER → insert
+    Running --> Running: LOOKUP → read
+    Running --> Running: UNREGISTER → delete
+    Running --> Running: LIST → snapshot
+    Running --> Stopping: SIGINT / SHUTDOWN
+    Stopping --> [*]: close socket, unlink .sock
+

Registry semantics

+ + + + + + + + + + + + + + + + + + + + + + + + + +
CaseResult
New topicInsert → OK
Same topic, same publisher_nodeOverwrite → OK (re-registration)
Same topic, different publisher_nodeReject → ALREADY_EXISTS
UNREGISTER missing topicNOT_FOUND
+

Client

+

Implemented in [DiscoveryClient][cortex.discovery.client.DiscoveryClient].

+

Thin REQ wrapper around the protocol. Important operational detail: REQ +sockets stick after a timeout — they block subsequent sends waiting for a +reply that never came. The client handles this by closing and recreating the +socket on every timeout (_reconnect). Callers don't see it.

+

REQ timeout recovery

+
flowchart TD
+    S[send request] --> W[wait RCVTIMEO]
+    W -->|reply| OK[return DiscoveryResponse]
+    W -->|timeout| T[zmq.Again]
+    T --> C[close REQ socket]
+    C --> N[create fresh REQ<br/>same endpoint]
+    N -->|attempts < retries| S
+    N -->|exhausted| F[raise TimeoutError]
+

Polling helpers

+
    +
  • [lookup_topic(name)][cortex.discovery.client.DiscoveryClient.lookup_topic] — + one-shot, returns None on miss.
  • +
  • [wait_for_topic(name, timeout, poll_interval)][cortex.discovery.client.DiscoveryClient.wait_for_topic] — + blocking poll loop (time.sleep).
  • +
  • [wait_for_topic_async(name, timeout, poll_interval)][cortex.discovery.client.DiscoveryClient.wait_for_topic_async] — + async poll loop (asyncio.sleep). This is what [Subscriber][cortex.core.subscriber.Subscriber] + uses when wait_for_topic=True.
  • +
+

Protocol

+

Implemented in cortex.discovery.protocol.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypePurpose
[DiscoveryCommand][cortex.discovery.protocol.DiscoveryCommand]REGISTER_TOPIC / UNREGISTER_TOPIC / LOOKUP_TOPIC / LIST_TOPICS / SHUTDOWN
[DiscoveryStatus][cortex.discovery.protocol.DiscoveryStatus]OK / NOT_FOUND / ALREADY_EXISTS / ERROR
[TopicInfo][cortex.discovery.protocol.TopicInfo]name, address, message_type, fingerprint, publisher_node
[DiscoveryRequest][cortex.discovery.protocol.DiscoveryRequest]command + optional topic_info / topic_name
[DiscoveryResponse][cortex.discovery.protocol.DiscoveryResponse]status, message, topic_info, topics
+

All payloads are msgpack. TopicInfo is nested as a packed sub-blob so +discovery responses stay flat.

+

Known limitations

+

Summarized here, detailed in critique.md:

+
    +
  • One-publisher-per-topic.
  • +
  • No heartbeats or leases — crashed publishers leave stale entries.
  • +
  • Single-threaded REP — slow client starves others.
  • +
  • retries=1 in the client is a fencepost; effective retries today is zero.
  • +
  • Daemon state lost on restart; publishers do not auto-re-register.
  • +
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/components/messages/index.html b/docs/site/components/messages/index.html new file mode 100644 index 0000000..1faf31b --- /dev/null +++ b/docs/site/components/messages/index.html @@ -0,0 +1,2013 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Messages - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + + + +

Messages

+
+

Source: cortex.messages.base, +cortex.messages.standard

+
+

Messages are just @dataclasses that inherit from +[Message][cortex.messages.base.Message]. Registering with the type system, +computing a fingerprint, and (de)serialization all happen automatically.

+

Anatomy of a message

+
classDiagram
+    class Message {
+        +fingerprint() int
+        +to_bytes() bytes
+        +to_frames() list
+        +from_bytes(data) tuple
+        +from_frames(frames) tuple
+        +decode(bytes) tuple [static]
+        -_build_header()
+        -_field_names() tuple
+        -_field_values() list
+        -_next_sequence() int
+    }
+    class MessageHeader {
+        +fingerprint: int
+        +timestamp_ns: int
+        +sequence: int
+        +to_bytes() bytes
+        +from_bytes(data) MessageHeader
+        +size() int
+    }
+    class MessageType {
+        +register(cls)
+        +get(fingerprint) type
+        +get_all() dict
+    }
+    Message ..> MessageHeader : emits
+    Message ..> MessageType : auto-registers on subclass
+

Defining a custom message

+
from dataclasses import dataclass
+import numpy as np
+from cortex.messages.base import Message
+
+@dataclass
+class JointTrajectory(Message):
+    timestamp: float
+    positions: np.ndarray   # shape (N,)
+    velocities: np.ndarray  # shape (N,)
+    frame_id: str = ""
+
+

That is the entire contract. The class is registered into +[MessageType._registry][cortex.messages.base.MessageType] by fingerprint at +import time, and gains:

+
    +
  • JointTrajectory.fingerprint() — 64-bit ID.
  • +
  • msg.to_frames() / JointTrajectory.from_frames(frames) — the transport path.
  • +
  • msg.to_bytes() / JointTrajectory.from_bytes(data) — the legacy blob path.
  • +
  • Message.decode(blob) — class dispatch via fingerprint registry.
  • +
+

Sequence numbering

+
+

Class-level counter

+

Message._sequence_counter is shared across all publisher instances of +the same message class in the process. Two ArrayMessage publishers +interleave sequence numbers. Per-topic gap detection therefore needs a +per-publisher counter today; see critique.md § 12.

+
+

Built-in messages

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassUse for
[StringMessage][cortex.messages.standard.StringMessage]Plain strings
[IntMessage][cortex.messages.standard.IntMessage] / [FloatMessage][cortex.messages.standard.FloatMessage]Single scalars
[BytesMessage][cortex.messages.standard.BytesMessage]Opaque binary
[DictMessage][cortex.messages.standard.DictMessage]Nested dicts with arrays/tensors
[ListMessage][cortex.messages.standard.ListMessage]Mixed-type lists
[ArrayMessage][cortex.messages.standard.ArrayMessage]Single NumPy array + name / frame_id
[MultiArrayMessage][cortex.messages.standard.MultiArrayMessage]dict[str, np.ndarray] (e.g. points+colors)
[TensorMessage][cortex.messages.standard.TensorMessage]PyTorch tensor (preserves device/grad)
[MultiTensorMessage][cortex.messages.standard.MultiTensorMessage]Named tensor bundle (model I/O)
[ImageMessage][cortex.messages.standard.ImageMessage]Image + encoding + width/height
[PointCloudMessage][cortex.messages.standard.PointCloudMessage]XYZ + optional RGB / intensity / normals
[PoseMessage][cortex.messages.standard.PoseMessage]6-DoF pose (position + quaternion)
[TransformMessage][cortex.messages.standard.TransformMessage]4×4 homogeneous transform
[TimestampMessage][cortex.messages.standard.TimestampMessage] / [HeaderMessage][cortex.messages.standard.HeaderMessage]ROS-style stamps
+

Encode / decode lifecycle

+
flowchart LR
+    A[User builds dataclass] --> B[Publisher.publish]
+    B --> C[message.to_frames]
+    C --> D[[ZMQ multipart send]]
+    D --> E[[ZMQ multipart recv]]
+    E --> F[Message.from_frames]
+    F --> G[user callback msg, header]
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/components/node-and-executors/index.html b/docs/site/components/node-and-executors/index.html new file mode 100644 index 0000000..64deade --- /dev/null +++ b/docs/site/components/node-and-executors/index.html @@ -0,0 +1,2166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Node & Executors - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + + + +

Node & Executors

+
+

Source: cortex.core.node, +cortex.core.executor

+
+

A [Node][cortex.core.node.Node] is the user-facing composition unit: it owns +a shared ZMQ async context and a collection of publishers, subscribers, and +timers. Executors provide the scheduling primitives that timers and +subscriber receive loops run on.

+

Responsibilities

+
flowchart TB
+    subgraph NodeResp[Node]
+        CTX[shared zmq.asyncio.Context]
+        PUBS[Publishers dict]
+        SUBS[Subscribers dict]
+        TIMERS[Timers list]
+    end
+
+    NodeResp -- create_publisher --> P[Publisher]
+    NodeResp -- create_subscriber --> S[Subscriber]
+    NodeResp -- create_timer --> RE[RateExecutor]
+    NodeResp -- run / close --> Lifecycle
+
+    P -. uses .-> CTX
+    S -. uses .-> CTX
+

One node = one process boundary in practice. Nothing stops you running +multiple nodes in the same process (asyncio.gather([n.run() for n in nodes]), +see examples/multi_node_system.py), +but remember they share the same event loop — a slow callback in one still +blocks the others.

+

Lifecycle

+
stateDiagram-v2
+    [*] --> Constructed: Node(name)
+    Constructed --> Configured: create_publisher/subscriber/timer
+    Configured --> Running: await node.run()
+    Running --> Running: timers fire, callbacks dispatch
+    Running --> Stopping: node.stop() or cancel
+    Stopping --> Closed: await node.close()
+    Closed --> [*]: context terminated
+

node.run()

+

Spawns one asyncio task per timer and one per callback-bearing subscriber, +then asyncio.gathers them. Returns when all tasks complete or the node is +stopped.

+
async with Node("my_node") as node:
+    node.create_publisher("/x", IntMessage)
+    node.create_subscriber("/y", IntMessage, callback=on_y)
+    await node.run()   # blocks until cancelled
+# __aexit__ calls close() automatically
+
+

node.close()

+

Stops all executors, cancels outstanding tasks, closes every publisher and +subscriber (each of which unregisters/unbinds their own socket), and +terminates the shared ZMQ context. Idempotent.

+

Executors

+

Two flavours, both subclasses of BaseExecutor.

+
classDiagram
+    class BaseExecutor {
+        <<abstract>>
+        +func: AsyncCallback
+        +start()
+        +stop()
+        +run(*args, **kwargs)
+        #_run_impl()*
+    }
+    class AsyncExecutor {
+        +_run_impl()
+    }
+    class RateExecutor {
+        +rate_hz: float
+        +interval: float
+        +_run_impl()
+    }
+    BaseExecutor <|-- AsyncExecutor
+    BaseExecutor <|-- RateExecutor
+

AsyncExecutor

+

"Run this coroutine as fast as possible, yielding between iterations."

+
flowchart LR
+    Start --> Check{running?}
+    Check -- no --> End
+    Check -- yes --> Call[await func]
+    Call -- exception --> Log[log error]
+    Log --> Sleep
+    Call --> Sleep[await sleep 0]
+    Sleep --> Check
+

Used by Subscriber.run to drive the receive-dispatch loop.

+

RateExecutor

+

"Run this coroutine at a constant rate, catching up on overruns."

+
flowchart TD
+    Start[next = perf_counter] --> Loop{running?}
+    Loop -- no --> End
+    Loop -- yes --> Now[now = perf_counter]
+    Now --> Due{now >= next?}
+    Due -- yes --> Call[await func]
+    Call --> Advance[next += interval]
+    Advance --> Behind{next < now?}
+    Behind -- yes --> Reset[next = now + interval]
+    Behind -- no --> Wait
+    Reset --> Wait
+    Due -- no --> Wait[await sleep next - now]
+    Wait --> Loop
+

The catch-up branch silently drops ticks — if your 100 Hz callback takes +20 ms once, you do not get two callbacks back-to-back; you skip one tick.

+
+

Redundant yield

+

Today there is an await asyncio.sleep(0) inside the loop and +await asyncio.sleep(max(0, dt)) at the bottom. That generates an extra +wakeup per tick. See critique § 15.

+
+

Timer usage

+
node.create_timer(1.0 / 30, self.publish_frame)   # 30 Hz
+node.create_timer(1.0, self.log_stats)            # 1 Hz
+
+

Timers are plain async functions — no decorator, no magic. They run in the +same event loop as subscriber callbacks, so the same head-of-line caveat +applies.

+

Shared ZMQ context

+

Every publisher and subscriber created through a node reuses the node's +zmq.asyncio.Context. This means:

+
    +
  • Socket creation is cheap.
  • +
  • io threads are shared across all sockets in the node.
  • +
  • Terminating the node's context cleanly shuts down all its sockets.
  • +
+

Do not create your own context inside callbacks; you'll leak resources and +defeat the shared-io-thread optimization.

+

Minimal complete node

+
from dataclasses import dataclass
+import numpy as np
+import cortex
+from cortex import Node, Message
+from cortex.messages.base import MessageHeader
+
+
+@dataclass
+class Ping(Message):
+    payload: np.ndarray
+    counter: int
+
+
+class Echo(Node):
+    def __init__(self):
+        super().__init__("echo")
+        self.pub = self.create_publisher("/pong", Ping)
+        self.create_subscriber("/ping", Ping, callback=self.on_ping)
+        self._n = 0
+
+    async def on_ping(self, msg: Ping, header: MessageHeader):
+        self._n += 1
+        self.pub.publish(Ping(payload=msg.payload, counter=self._n))
+
+
+async def main():
+    async with Echo() as node:
+        await node.run()
+
+
+if __name__ == "__main__":
+    cortex.run(main())
+
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/components/publisher-subscriber/index.html b/docs/site/components/publisher-subscriber/index.html new file mode 100644 index 0000000..cbed7f8 --- /dev/null +++ b/docs/site/components/publisher-subscriber/index.html @@ -0,0 +1,2428 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Publisher & Subscriber - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + +

Publisher & Subscriber

+
+

Source: cortex.core.publisher, +cortex.core.subscriber

+
+

The data-plane workhorses. A Publisher binds a ZMQ PUB socket and registers +with discovery; a Subscriber looks up the endpoint, connects a SUB socket, +and drives an async receive loop. Discovery is consulted once per topic on +startup — it is not on the hot path.

+

Relationship to the rest of the stack

+
flowchart LR
+    Node -.owns.-> P[Publisher]
+    Node -.owns.-> S[Subscriber]
+    P -- register --> DC1[DiscoveryClient]
+    S -- lookup --> DC2[DiscoveryClient]
+    P -- send_multipart --> Sock1[(zmq.PUB<br/>IPC)]
+    Sock1 -. IPC .-> Sock2[(zmq.SUB)]
+    S -- recv_multipart --> Sock2
+    M[Message] -- to_frames --> P
+    S -- from_frames --> M
+

Publisher

+

Construction

+

Always create via [Node.create_publisher][cortex.core.node.Node.create_publisher] — +direct construction works but skips the shared ZMQ context reuse and the +node-level registration bookkeeping.

+
pub = node.create_publisher(
+    topic_name="/camera/image",   # must start with "/"
+    message_type=ImageMessage,    # fingerprint is taken from this class
+    queue_size=100,               # SNDHWM; drops under backpressure
+)
+
+

Startup sequence

+
sequenceDiagram
+    autonumber
+    participant U as User
+    participant Pub as Publisher
+    participant FS as /tmp/cortex/topics/
+    participant ZMQ as zmq.PUB
+    participant D as Discovery daemon
+
+    U->>Pub: __init__(topic, msg_cls, ...)
+    Pub->>Pub: address = generate_ipc_address(topic, node)
+    Pub->>FS: mkdir -p; unlink stale .sock
+    Pub->>ZMQ: socket(PUB); setsockopt HWM/LINGER; bind(address)
+    Pub->>D: REGISTER TopicInfo{name, address, fingerprint, node}
+    D-->>Pub: OK / ALREADY_EXISTS
+    Note over Pub: ready; user can publish()
+

Two things worth calling out:

+
    +
  1. The IPC address is derived deterministically from node_name and + topic_name via [generate_ipc_address][cortex.core.publisher.generate_ipc_address]: + ipc:///tmp/cortex/topics/<node>__<topic-with-slashes-as-underscores>.sock.
  2. +
  3. _setup_socket unlinks any existing file at that path before binding. That + protects against crash-leftover sockets, but also means two publishers + configured with the same node_name + topic_name in the same process tree + will silently stomp each other — see critique § 10.
  4. +
+

Publish path

+
flowchart LR
+    Msg[Message dataclass] --> H[build MessageHeader<br/>fp, ts, seq]
+    Msg --> V[serialize_message_frames<br/>values]
+    H --> F[[frame 1: header 24B]]
+    V --> F2[[frame 2: msgpack metadata]]
+    V --> FN[[frames 3..N: array buffers]]
+    T[[frame 0: topic bytes]]
+    F --> Send
+    F2 --> Send
+    FN --> Send
+    T --> Send
+    Send[send_multipart NOBLOCK] -->|success| Pub[publish count++]
+    Send -->|zmq.Again| Drop[return False]
+

publish() is synchronous and returns a boolean:

+
    +
  • True — handed to ZMQ successfully.
  • +
  • Falsezmq.Again, queue full, message dropped.
  • +
+

Any other exception is logged and swallowed; publish still returns False. +For robotics code this "fire and forget" is intentional — the caller decides +whether to retry based on the return value and the topic's role.

+

Async context quirk

+

Node owns a zmq.asyncio.Context. The Publisher constructor detects this +and wraps a sync zmq.Context around the same underlying io threads:

+
if isinstance(self._context, zmq.asyncio.Context):
+    self._context: zmq.Context = zmq.Context(self._context)
+
+

This keeps publish() a normal function call instead of forcing every publish +to be awaited. It is the right performance choice, but it has consequences:

+
+

zmq.PUB is not thread-safe

+

Do not call publish() on the same Publisher from multiple threads +(or multiple asyncio tasks that could race on send_multipart). Serialize +per-publisher calls yourself if you fan out work.

+
+

Lifecycle and cleanup

+
stateDiagram-v2
+    [*] --> Bound: bind + register
+    Bound --> Publishing: publish() calls
+    Publishing --> Publishing: more messages
+    Publishing --> Closed: close()
+    Bound --> Closed: close()
+    Closed --> [*]: unregister,<br/>unlink .sock file
+

Publisher.close() is best-effort: it unregisters from the daemon (silently +tolerates a dead daemon), closes the socket, and removes the IPC file. +Exceptions from any one step do not block the others.

+

Statistics

+

publisher.publish_count, publisher.last_publish_time, and +publisher.is_registered are exposed for instrumentation. They update on the +hot path with no locking — read them from the same task that calls publish() +for deterministic numbers.

+

Subscriber

+

Construction

+
sub = node.create_subscriber(
+    topic_name="/camera/image",
+    message_type=ImageMessage,
+    callback=on_image,          # async def callback(msg, header)
+    queue_size=10,              # RCVHWM
+    wait_for_topic=True,        # poll until topic appears
+    topic_timeout=30.0,         # abort wait after N seconds
+)
+
+

If callback is None, the subscriber is passive — call await sub.receive() +manually. With a callback, Node.run() will drive the receive loop.

+

Startup sequence

+
sequenceDiagram
+    autonumber
+    participant U as User
+    participant S as Subscriber
+    participant D as DiscoveryClient
+    participant Pub as publisher IPC
+
+    U->>S: __init__(...)
+    S->>D: lookup_topic(name)  # non-blocking
+    alt found immediately
+        D-->>S: TopicInfo
+        S->>S: verify fingerprint
+        S->>Pub: SUB connect + SUBSCRIBE topic
+        Note over S: is_connected = True
+    else not found
+        D-->>S: None
+        Note over S: defer; retry in run()
+    end
+
+    U->>S: node.run() schedules sub.run()
+    S->>D: wait_for_topic_async(name, timeout)
+    D-->>S: TopicInfo
+    S->>Pub: SUB connect + SUBSCRIBE topic
+

The constructor tries a non-blocking lookup first so that when a publisher is +already up, no polling is needed. The polling fallback only kicks in inside +sub.run() via [wait_for_topic_async][cortex.discovery.client.DiscoveryClient.wait_for_topic_async].

+

Receive loop

+
flowchart LR
+    Loop{{AsyncExecutor}} --> Recv[await recv_multipart copy=False]
+    Recv --> Frames[frames = topic, header, metadata, *buffers]
+    Frames --> Decode[Message.from_frames frames 1..]
+    Decode --> CB[await callback msg, header]
+    CB --> Yield[await asyncio.sleep 0]
+    Yield --> Loop
+
    +
  • copy=False means each frame is a zmq.Frame — the metadata and array + buffers are memoryview-able without a copy. See + cortex.utils.serialization.
  • +
  • The one-frame fast path (len(payload_frames) == 1) handles legacy + publishers still on the single-blob path — it falls back to + from_bytes on the single payload buffer.
  • +
+

Head-of-line blocking

+

The callback runs inline in the receive loop. A slow callback stalls +everything:

+
gantt
+    title Receive loop when callback is slow
+    dateFormat X
+    axisFormat %L ms
+    section Messages
+    recv m1       :0, 1
+    decode m1     :1, 2
+    callback m1 (slow!) :active, 2, 50
+    recv m2 (queued on HWM) :crit, 50, 51
+    decode m2     :51, 52
+    callback m2   :52, 55
+

If callbacks do meaningful work, dispatch them to a task or thread pool:

+
import asyncio
+
+async def on_image(msg, header):
+    asyncio.create_task(process_in_background(msg, header))
+
+

Or use a bounded queue + worker pattern. The roadmap item in +critique § 6 is to lift this into the framework.

+

Fingerprint verification

+

On connect the subscriber compares its class's fingerprint to the one in the +registry entry. Today a mismatch only logs a warning and proceeds anyway — +downstream decoding will then fail hard. Treat fingerprint warnings as errors +in your code.

+

Cleanup

+

Subscriber.close() stops the executor, closes the discovery client and SUB +socket, and flips is_connected to False. Safe to call multiple times; +errors are suppressed so teardown does not cascade.

+

Statistics and instrumentation

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyPublisherSubscriber
publish_count / receive_count
last_publish_time / last_receive_time
is_registered / is_connected
topic_info
+

None of these are atomic; treat them as coarse gauges.

+

Common pitfalls

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SymptomCauseFix
First N messages not receivedZMQ "slow joiner": SUB not connected yet when PUB started publishingLet subscriber start first, or sleep briefly before first publish
Subscriber receives nothing, no errorsTopic name mismatch, or forgot to call node.run()Log both sides; run cortex-discovery --log-level DEBUG
publish() returns False repeatedlySubscriber can't keep up; SNDHWM reachedIncrease queue_size, or reduce publish rate
Mutating a received array "corrupts" laterDecoded arrays alias ZMQ frame memoryarr = arr.copy() before mutating
Two processes stomp each other's socketSame node_name + topic_nameUnique node names per process
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/components/serialization/index.html b/docs/site/components/serialization/index.html new file mode 100644 index 0000000..e1d5ee5 --- /dev/null +++ b/docs/site/components/serialization/index.html @@ -0,0 +1,2077 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Serialization - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + +

Serialization

+
+

Source: cortex.utils.serialization, +cortex.utils.hashing

+
+

Two encodings live side by side: a multipart / out-of-band path that the +transport actually uses, and a single-blob path kept for the legacy +Message.to_bytes / decode API and tests. Both support the same Python +types; only their frame layout differs.

+

Supported types

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeInline path (to_bytes)OOB path (to_frames)
None1 byte tagmsgpack nil
int, float, str, boolmsgpack PRIMITIVEmsgpack
bytestag + length + bytesmsgpack bin
list, tuple, dictmsgpack with ExtType arraysmsgpack with OOB descriptors
np.ndarrayExtType (inline bytes)OOB descriptor + extra frame
torch.TensorExtType (inline bytes)OOB descriptor + extra frame
+

The two paths, side by side

+
+
+
+
flowchart LR
+    V[values] --> E[_encode_transport_value]
+    E --> Meta[msgpack metadata<br/>OOB descriptors for arrays]
+    E --> Bufs[[buffer 0]]
+    E --> Bufs2[[buffer 1]]
+    Meta --> Out[(list of frames)]
+    Bufs --> Out
+    Bufs2 --> Out
+

The function of interest is +[serialize_message_frames][cortex.utils.serialization.serialize_message_frames]:

+
metadata_bytes, [buf0, buf1, ...] = serialize_message_frames(values)
+
+

Arrays stay contiguous; ZMQ hands the buffer straight to the kernel.

+
+
+
flowchart LR
+    V[values] --> P[msgpack.packb<br/>default=_msgpack_default]
+    P --> Ext[ExtType 1/2 for arrays/tensors<br/>bytes embedded]
+    Ext --> Blob[single bytes blob]
+

The single blob round-trips through serialize(value) → +deserialize(data). Useful for persisting to disk, caches, or when you +need a self-contained payload without tracking extra buffers.

+
+
+
+

OOB descriptors

+

An out-of-band descriptor is a small dict that takes the place of the array +inside the msgpack metadata:

+
# numpy
+{"__cortex_oob__": "numpy", "buffer": 0, "dtype": "<f4", "shape": [480, 640, 3]}
+
+# torch
+{"__cortex_oob__": "torch", "buffer": 1, "dtype": "<f4",
+ "shape": [1, 3, 224, 224], "device": "cuda:0", "requires_grad": True}
+
+

The buffer index refers into the ZMQ frames that follow the metadata. +Nested structures (dict of arrays, list of tensors, etc.) are walked +recursively by _encode_transport_value / _decode_transport_value.

+

Zero-copy on the decode side

+
sequenceDiagram
+    participant Sub as Subscriber
+    participant ZMQ as zmq.Frame
+    participant MV as memoryview
+    participant NP as np.ndarray
+
+    Sub->>ZMQ: recv_multipart(copy=False)
+    ZMQ-->>Sub: frame with .buffer property
+    Sub->>MV: memoryview(frame.buffer)
+    Sub->>NP: np.frombuffer(mv, dtype).reshape(shape)
+    Note over NP: array aliases the ZMQ frame memory
+
+

Aliasing caveat

+

The returned NumPy array is a view over the ZMQ frame buffer. It is +safe to read as long as the frame lives, which is at least until your +callback returns. If you need to:

+
    +
  • mutate the array, or
  • +
  • keep it past the callback,
  • +
+

call arr = arr.copy() first. This is cheap compared to the savings on +the hot path.

+
+

PyTorch specifics

+
    +
  • Tensors are always moved to CPU for transport. Transport frames carry + the tensor's CPU bytes plus the original device string.
  • +
  • On decode, CUDA tensors are moved back to the original device when CUDA is + available; otherwise they stay on CPU.
  • +
  • requires_grad is preserved.
  • +
+

Fingerprinting

+

Separate but related: [compute_fingerprint(cls)][cortex.utils.hashing.compute_fingerprint] +computes a 64-bit identity from the module path, class name, and sorted +field:type pairs. Cached per-class in _fingerprint_cache. See +Concepts → Fingerprinting for the full story.

+

When to use each helper

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HelperUse when
[serialize_message_frames][cortex.utils.serialization.serialize_message_frames]You're building a custom transport that speaks multipart
[deserialize_message_frames][cortex.utils.serialization.deserialize_message_frames]Decoding the above
[serialize(value)][cortex.utils.serialization.serialize] / [deserialize][cortex.utils.serialization.deserialize]Persisting a single value to disk / cache
[serialize_numpy][cortex.utils.serialization.serialize_numpy] / [deserialize_numpy][cortex.utils.serialization.deserialize_numpy]Raw array round-trip without msgpack overhead
Message.to_frames / Message.from_framesAnything inside Cortex itself
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/concepts/architecture/index.html b/docs/site/concepts/architecture/index.html new file mode 100644 index 0000000..1f8871b --- /dev/null +++ b/docs/site/concepts/architecture/index.html @@ -0,0 +1,1902 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Architecture - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Architecture

+

Cortex has three moving parts: the discovery daemon, publisher nodes, +and subscriber nodes. They coordinate over ZeroMQ — a REQ/REP control plane +for discovery and a PUB/SUB data plane for messages.

+

High-level view

+
flowchart TB
+    subgraph CP[Control plane]
+        DD[Discovery daemon<br/><small>ipc:///tmp/cortex/discovery.sock</small>]
+    end
+
+    subgraph DP[Data plane]
+        direction LR
+        P[Publisher node] -- "PUB / SUB (IPC)" --> S[Subscriber node]
+    end
+
+    P -- REGISTER --> DD
+    S -- LOOKUP --> DD
+    DD -- TopicInfo --> S
+
+    classDef daemon fill:#6366f1,stroke:#312e81,color:#fff
+    classDef node fill:#0ea5e9,stroke:#0369a1,color:#fff
+    class DD daemon
+    class P,S node
+

Message journey

+

Tracing one frame end to end:

+
sequenceDiagram
+    autonumber
+    participant User as User code
+    participant Pub as Publisher
+    participant Sock as ZMQ PUB socket
+    participant Net as IPC
+    participant SSock as ZMQ SUB socket
+    participant Sub as Subscriber
+    participant CB as async callback
+
+    User->>Pub: publish(Message)
+    Pub->>Pub: build header (fingerprint, ts, seq)
+    Pub->>Pub: encode field values + OOB buffers
+    Pub->>Sock: send_multipart([topic, header, metadata, *buffers])
+    Sock->>Net: zero-copy handoff
+    Net->>SSock: frames delivered
+    SSock->>Sub: recv_multipart(copy=False)
+    Sub->>Sub: Message.from_frames(...)
+    Sub->>CB: await callback(msg, header)
+

Key invariant: array buffers ride as separate ZMQ frames, not inline in the +metadata. See Message wire format.

+

Process layout

+
flowchart LR
+    subgraph P1[Process: sensor]
+        N1[Node<br/>shared zmq.asyncio.Context]
+        PUB1[Publisher /sensor/a]
+        PUB2[Publisher /sensor/b]
+        T1[Timer 30 Hz]
+        N1 --> PUB1
+        N1 --> PUB2
+        N1 --> T1
+    end
+
+    subgraph P2[Process: processor]
+        N2[Node]
+        SUB1[Subscriber /sensor/a]
+        SUB2[Subscriber /sensor/b]
+        PUB3[Publisher /processed]
+        N2 --> SUB1
+        N2 --> SUB2
+        N2 --> PUB3
+    end
+
+    PUB1 -.->|IPC| SUB1
+    PUB2 -.->|IPC| SUB2
+

Each topic gets its own IPC socket under /tmp/cortex/topics/. A single Node +shares one zmq.asyncio.Context across all its publishers and subscribers to +avoid per-socket io thread overhead.

+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/concepts/async-execution-model/index.html b/docs/site/concepts/async-execution-model/index.html new file mode 100644 index 0000000..fb398e4 --- /dev/null +++ b/docs/site/concepts/async-execution-model/index.html @@ -0,0 +1,1937 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Async execution model - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + + + +

Async execution model

+

Cortex nodes are asyncio-native. One event loop per process drives all +publishers, subscribers, and timers for that node. On Linux and macOS, +[cortex.run][cortex.utils.loop.run] prefers uvloop for lower tail latency.

+

Node task graph

+
flowchart TB
+    Loop(((asyncio event loop)))
+    Loop --> T1[Timer 1<br/>RateExecutor]
+    Loop --> T2[Timer 2<br/>RateExecutor]
+    Loop --> S1[Subscriber 1<br/>AsyncExecutor]
+    Loop --> S2[Subscriber 2<br/>AsyncExecutor]
+

Node.run() spawns one task per timer (RateExecutor) and one per +callback-bearing subscriber (AsyncExecutor). It then asyncio.gathers them +until cancelled.

+

RateExecutor cadence

+
sequenceDiagram
+    participant L as Event loop
+    participant R as RateExecutor
+    participant CB as callback
+
+    loop every interval
+        L->>R: resume
+        R->>CB: await callback()
+        R->>R: next_exec_time += interval
+        alt fell behind
+            R->>R: next_exec_time = now + interval
+        end
+        R->>L: sleep(next_exec_time - now)
+    end
+

Catch-up logic silently drops ticks when a callback overruns its period — +something to keep in mind for control loops.

+

AsyncExecutor receive loop

+
sequenceDiagram
+    participant L as Event loop
+    participant A as AsyncExecutor
+    participant S as SUB socket
+    participant CB as user callback
+
+    loop while running
+        L->>A: resume
+        A->>S: await recv_multipart(copy=False)
+        S-->>A: frames
+        A->>A: decode message
+        A->>CB: await callback(msg, header)
+        A->>L: sleep(0)  (yield)
+    end
+
+

Head-of-line blocking

+

A slow callback stalls the receive loop. Messages pile up on the SUB HWM +and get evicted. If you expect variable-latency work, offload callback +bodies to asyncio.create_task(...) or a thread pool.

+
+

Publish is sync-inside-async

+

The Publisher uses a sync zmq.Context (shadowed onto the node's async +context). publish() is a plain function call — no await. This avoids the +overhead of the async zmq integration on the send path.

+
+

Not thread-safe

+

A zmq.PUB socket is not safe to call from multiple threads or tasks +concurrently. Serialize calls to publish() per publisher.

+
+

uvloop

+

On Unix, importing cortex.run checks for uvloop and uses it if present. +Measured impact: modest throughput improvement, meaningful p99 latency +reduction on high-rate small messages.

+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/concepts/discovery-protocol/index.html b/docs/site/concepts/discovery-protocol/index.html new file mode 100644 index 0000000..d6af052 --- /dev/null +++ b/docs/site/concepts/discovery-protocol/index.html @@ -0,0 +1,2027 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Discovery protocol - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + +

Discovery protocol

+

The discovery daemon speaks a tiny msgpack-over-REQ/REP protocol. It is not +on the data path — once a subscriber has the endpoint, messages flow +publisher → subscriber directly.

+

Commands

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CommandPayload requiredReturns
REGISTER_TOPIC (1)[TopicInfo][cortex.discovery.protocol.TopicInfo]OK / ALREADY_EXISTS
UNREGISTER_TOPIC (2)topic_name or TopicInfo.nameOK / NOT_FOUND
LOOKUP_TOPIC (3)topic_nameOK + TopicInfo / NOT_FOUND
LIST_TOPICS (4)OK + list[TopicInfo]
SHUTDOWN (99)OK; daemon exits
+

Status codes: OK=0, NOT_FOUND=1, ALREADY_EXISTS=2, ERROR=3.

+

TopicInfo payload

+
@dataclass
+class TopicInfo:
+    name: str              # "/camera/image"
+    address: str           # "ipc:///tmp/cortex/topics/cam__camera_image.sock"
+    message_type: str      # "ImageMessage"
+    fingerprint: int       # 64-bit class fingerprint
+    publisher_node: str    # "cam"
+
+

Publisher register flow

+
sequenceDiagram
+    autonumber
+    participant P as Publisher
+    participant D as Daemon REP
+
+    P->>P: bind PUB socket on ipc:///tmp/cortex/topics/<node>__<topic>.sock
+    P->>D: REQ → DiscoveryRequest(REGISTER_TOPIC, TopicInfo{...})
+    D->>D: if topic_name absent: insert; else compare publisher_node
+    alt new
+        D-->>P: OK "Registered topic: /x"
+    else same publisher re-registering
+        D-->>P: OK (overwrite)
+    else different publisher, same topic
+        D-->>P: ALREADY_EXISTS
+    end
+

Subscriber lookup flow

+
sequenceDiagram
+    autonumber
+    participant S as Subscriber
+    participant D as Daemon REP
+    participant P as Publisher
+
+    S->>D: REQ → LOOKUP_TOPIC("/x")
+    alt present
+        D-->>S: OK + TopicInfo
+        S->>P: SUB connect + SUBSCRIBE "/x"
+    else missing
+        D-->>S: NOT_FOUND
+        Note over S: if wait_for_topic:<br/>poll every 500 ms until timeout
+        S->>D: retry LOOKUP_TOPIC
+    end
+

wait_for_topic_async implements the retry loop with asyncio.sleep so the +event loop keeps spinning.

+

REQ-socket recovery

+

ZMQ REQ sockets enter a bad state after a missed reply — they block further +sends. The client detects zmq.Again on timeout and rebuilds the socket:

+
flowchart TD
+    A[send request] -->|timeout| B[REQ socket stuck]
+    B --> C[close socket]
+    C --> D[recreate socket<br/>same endpoint]
+    D --> E[retry up to retries]
+

See [DiscoveryClient._reconnect][cortex.discovery.client.DiscoveryClient].

+
+

Fencepost in retries default

+

retries=1 today executes the loop exactly once — i.e. no retry. Bump to +retries=3 in client-side code if you need resilience.

+
+

Failure modes & how Cortex handles them

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScenarioBehavior
Daemon not running when publisher startsRegister fails; publisher still publishes, but no subscriber can find it.
Daemon restartsAll state lost; publishers must re-register. Current design has no auto-re-register.
Publisher crashesRegistry keeps stale TopicInfo until someone UNREGISTERs.
Two publishers, same topicSecond registration rejected with ALREADY_EXISTS.
Subscriber looks up before publisherNOT_FOUND; caller may wait_for_topic to poll.
+

Roadmap items (see critique.md) to address these: leases with +heartbeats, multi-publisher support, and notify-on-change.

+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/concepts/fingerprinting/index.html b/docs/site/concepts/fingerprinting/index.html new file mode 100644 index 0000000..8775a32 --- /dev/null +++ b/docs/site/concepts/fingerprinting/index.html @@ -0,0 +1,1950 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fingerprinting - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + + + +

Fingerprinting

+

Every message class gets a 64-bit identifier derived from its name and +field schema. The fingerprint rides in the header of every published message +and does two jobs:

+
    +
  1. Type dispatchMessage.decode(bytes) looks up the right class in the + [MessageType][cortex.messages.base.MessageType] registry.
  2. +
  3. Compatibility check — subscribers verify that the topic they looked up + advertises the same fingerprint as the type they were written against.
  4. +
+

Derivation

+
flowchart LR
+    A[class.__module__ + qualname] --> C[canonical string]
+    B[sorted list of field:type] --> C
+    C --> H[SHA-256]
+    H --> F[first 8 bytes → u64 big-endian]
+

Pseudocode:

+
canonical = f"{cls.__module__}.{cls.__qualname__}|{','.join(sorted('name:type'))}"
+fingerprint = int.from_bytes(sha256(canonical.encode()).digest()[:8], "big")
+
+

The result is cached per-class in _fingerprint_cache, computed once lazily.

+

Registry

+

Message.__init_subclass__ auto-registers every concrete subclass into +[MessageType._registry][cortex.messages.base.MessageType] keyed by +fingerprint. Nothing else to do — decorating your dataclass with +@dataclass and inheriting from Message is enough.

+
from dataclasses import dataclass
+from cortex.messages.base import Message
+
+@dataclass
+class JointState(Message):
+    positions: list[float]
+    velocities: list[float]
+
+print(hex(JointState.fingerprint()))
+
+

When fingerprints change

+

The fingerprint is not stable across edits that touch:

+
    +
  • Module path or class name (cortex.messages.standard.ArrayMessage renamed + anywhere).
  • +
  • Field names.
  • +
  • Field type annotations as spelled (see the PEP 563 caveat below).
  • +
+

It is stable across:

+
    +
  • Adding/removing unrelated classes.
  • +
  • Reordering methods.
  • +
  • Changing docstrings or default values.
  • +
+

Subscriber check

+

On connect, the subscriber compares the topic's advertised fingerprint against +the one it computed from its message class:

+
sequenceDiagram
+    participant S as Subscriber
+    participant D as Discovery daemon
+
+    S->>D: LOOKUP /topic
+    D-->>S: TopicInfo(fingerprint=0xABCD...)
+    S->>S: compare with MyMessage.fingerprint()
+    alt mismatch
+        S-->>S: log warning, continue anyway
+    else match
+        S-->>S: connect and subscribe
+    end
+
+

Today: mismatch is a warning, not an error

+

A fingerprint mismatch currently only logs a warning — see critique.md. +Downstream decoding will fail hard. Until that is tightened, prefer to +re-exchange type definitions between processes rather than rely on this guard.

+
+

PEP 563 caveat

+

field.type may be a string (under from __future__ import annotations) +or a real type otherwise. The canonical string differs in the two cases, +so the same class can fingerprint differently across import environments.

+

When defining messages shared between processes, either use the same import +style in both, or rely on the runtime typing.get_type_hints(cls) equivalent +once that lands upstream.

+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/concepts/message-wire-format/index.html b/docs/site/concepts/message-wire-format/index.html new file mode 100644 index 0000000..6c772bd --- /dev/null +++ b/docs/site/concepts/message-wire-format/index.html @@ -0,0 +1,1965 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Message wire format - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + + + +

Message wire format

+

Cortex uses ZeroMQ multipart messages. Each published message is a list of +frames rather than a single blob. That lets array payloads ride as raw +contiguous buffers — no copy into a Python bytes, no re-copy by ZMQ.

+

Frames on the wire

+
flowchart LR
+    F0["Frame 0<br/>topic bytes"] --> F1
+    F1["Frame 1<br/>header (24B)<br/>fingerprint • ts_ns • seq"] --> F2
+    F2["Frame 2<br/>msgpack metadata<br/>(ordered field values)"] --> F3
+    F3["Frame 3..N<br/>raw array buffers<br/>(OOB, zero-copy)"]
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FrameContentsSize
0Topic name (UTF-8)variable
1[MessageHeader][cortex.messages.base.MessageHeader]24 bytes (3 × u64, big-endian)
2msgpack-packed ordered field values; arrays replaced by OOB descriptorssmall
3..Nnp.ndarray.tobytes() / tensor.numpy().tobytes(), contiguouspayload-sized
+

Header layout

+
offset 0        8       16       24
+       |fp u64 |ts u64 |seq u64 |
+        big-endian throughout
+
+
    +
  • fp — 64-bit message fingerprint, computed from class name and field schema.
  • +
  • ts — publisher wall-clock in nanoseconds (time.time_ns()).
  • +
  • seq — per-process, per-message-type monotonic counter.
  • +
+

Metadata (Frame 2)

+

Field values are packed in declaration order (not by name), so the receiver +reconstructs using the dataclass's cached field tuple. This removes per-message +field-name encoding.

+

Arrays and tensors appear in the metadata as small dict stand-ins called +OOB descriptors:

+
{
+  "__cortex_oob__": "numpy",
+  "buffer": 0,
+  "dtype": "<f4",
+  "shape": [480, 640, 3]
+}
+
+

The buffer index refers into Frames 3..N. The receiver reconstructs:

+
np.frombuffer(frame.buffer, dtype=np.dtype(desc["dtype"])).reshape(desc["shape"])
+
+

No copy. The resulting array aliases the ZMQ frame memory — copy it if you +need ownership or mutability (see Performance tuning).

+

Full encode/decode flow

+
sequenceDiagram
+    participant U as User
+    participant M as Message.to_frames
+    participant S as serialize_message_frames
+    participant E as _encode_transport_value
+    participant Z as ZMQ send_multipart
+
+    U->>M: build header + collect field values
+    M->>S: values in declaration order
+    S->>E: for each value, walk nested dicts/lists
+    E-->>S: scalar stays inline; array → OOB descriptor + buffer appended
+    S-->>M: (metadata_bytes, [buf0, buf1, ...])
+    M-->>Z: [topic, header, metadata, *buffers]
+

The legacy single-blob path

+

Message.to_bytes() / from_bytes() / Message.decode() still exist. They +pack everything into one msgpack blob using ExtType for arrays. That path +is retained for tests and opportunistic use; the transport always uses the +multipart path above.

+
+

Mismatch trap

+

Bytes captured from the wire cannot be fed to Message.decode() — the wire +format is multipart, not a single blob. Use Message.from_frames(frames).

+
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/concepts/transport-and-qos/index.html b/docs/site/concepts/transport-and-qos/index.html new file mode 100644 index 0000000..0fb0e62 --- /dev/null +++ b/docs/site/concepts/transport-and-qos/index.html @@ -0,0 +1,1867 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Transport & QoS - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Transport & QoS

+

Stub — deep dive coming in a later pass.

+

Current socket settings

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SocketOptionValueNotes
Publisher PUBSNDHWM10 (default queue_size)Drops under backpressure
Publisher PUBLINGER0Immediate close
Subscriber SUBRCVHWM10Oldest messages evicted when full
Subscriber SUBLINGER0
Daemon REPRCVTIMEO1000 msAllows Ctrl-C responsiveness
Daemon REPLINGER0
+

Today's delivery semantics

+
    +
  • Publisher uses zmq.NOBLOCK: if the send queue is full, the message is + silently dropped.
  • +
  • Subscriber HWM is a ring buffer: old messages are silently evicted on + overflow.
  • +
+

This is fine for best-effort telemetry. It is unsafe for control commands.

+

Planned QoS profiles

+

Taking inspiration from DDS, three profiles are enough for most robotics use:

+
    +
  • best_effort_latest — conflate; keep only newest (camera frames).
  • +
  • reliable_queue — publisher blocks or errors (control commands).
  • +
  • dropping_queue — current behavior with an exposed drop counter (telemetry).
  • +
+

See critique.md § 4 for rationale.

+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/critique/index.html b/docs/site/critique/index.html new file mode 100644 index 0000000..601f172 --- /dev/null +++ b/docs/site/critique/index.html @@ -0,0 +1,2223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cortex Critique - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + +

Cortex Critique

+

A bottom-up review of Cortex as it stands today, with a focus on its viability as a communication library for robotics. This complements design-review.md with concrete code-level findings and benchmark observations.

+

How Cortex works (bottom-up)

+

1. Fingerprinting — utils/hashing.py

+

A message class's identity is a 64-bit integer:

+
fingerprint = SHA-256(f"{module}.{qualname}|{','.join(sorted('field:type'))}")[:8]
+
+
    +
  • Computed lazily and cached in _fingerprint_cache.
  • +
  • field.type is a string when from __future__ import annotations is active and a real type otherwise. The fingerprint therefore depends on how the module was imported — fragile for cross-repo use.
  • +
  • Field ordering is sorted alphabetically in the fingerprint, but the wire layout uses dataclass declaration order. Two classes could theoretically fingerprint identically but interpret the wire differently.
  • +
+

2. Message base — messages/base.py

+

Each dataclass inheriting Message is auto-registered via __init_subclass__ into MessageType._registry[fingerprint] = cls.

+

Wire format (multipart transport, what publishers actually use):

+
Frame 0: topic bytes            (for PUB/SUB filter)
+Frame 1: 24-byte header         (fingerprint u64, timestamp_ns u64, sequence u64, big-endian)
+Frame 2: msgpack of ordered field values with OOB descriptors
+Frame 3..N: raw contiguous array buffers (zero-copy)
+
+

There is a second, legacy single-blob path (to_bytes / from_bytes) that embeds array bytes inside a single msgpack blob using ExtType. It is retained for Message.decode(...) and tests, but is not what the transport uses.

+

3. Serialization — utils/serialization.py

+

Two strategies coexist:

+
    +
  • _msgpack_default / _msgpack_ext_hook (inline): arrays/tensors get packed as msgpack ExtType inside the single blob. Used by the legacy path.
  • +
  • _encode_transport_value / _decode_transport_value (out-of-band): each array/tensor is replaced with a tiny dict {__cortex_oob__: "numpy", buffer: i, dtype, shape} and its raw bytes are appended as separate ZMQ frames. Reconstruction uses np.frombuffer(frame.buffer, dtype).reshape(shape) with no copy.
  • +
+

After the March 2026 optimizations: zero-copy decode, schema-ordered values (field names no longer repeated per message), and cached field-name tuples.

+

4. Discovery — discovery/daemon.py and discovery/client.py

+

Single-threaded zmq.REP over IPC at ipc:///tmp/cortex/discovery.sock.

+
    +
  • Registry is a plain dict[str, TopicInfo], enforcing one publisher per topic.
  • +
  • RCVTIMEO=1s so the run loop can poll _running for Ctrl-C.
  • +
  • Commands: REGISTER, UNREGISTER, LOOKUP, LIST, SHUTDOWN.
  • +
  • Request/response payloads are msgpack.
  • +
  • Client uses REQ with close-and-recreate on timeout (REQ sockets are stuck after a missed reply).
  • +
+

5. Publisher / Subscriber — core/publisher.py, core/subscriber.py

+
    +
  • Publisher: binds a zmq.PUB at ipc:///tmp/cortex/topics/<node>__<topic>.sock, registers via the discovery client, publishes multipart [topic, header, metadata, *buffers] with zmq.NOBLOCK. If the Node hands it an async context, it wraps a sync zmq.Context(self._context) around the same underlying zmq io threads so publishing stays synchronous.
  • +
  • Subscriber: uses an async context, looks up the topic (optionally waits), connects zmq.SUB, sets a topic filter, loops via AsyncExecutor doing recv_multipart(copy=False)Message.from_frames.
  • +
+

6. Node + Executors — core/node.py, core/executor.py

+

A Node owns a shared zmq.asyncio.Context, plus lists of publishers, subscribers, and timers. Each timer gets a RateExecutor(fn, rate_hz). node.run() creates asyncio tasks for every timer and every callback-subscriber, then asyncio.gather. RateExecutor uses perf_counter plus asyncio.sleep(max(0, next-now)). cortex.run prefers uvloop on Unix.

+

Benchmark results

+

Measured on this machine with the in-repo benchmark suite:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValue
Small-payload latencymean 556 µs, p99 1075 µs
64KB latencymean 919 µs, p99 1.4 ms
Tiny array throughput21.8k msg/s
1MB array throughput7.7k msg/s, 8.0 GB/s
4MB array throughput2.25k msg/s, 9.4 GB/s
1080p RGB frames1422 fps, 8.8 GB/s
Raw wire+decode (inproc)35 µs roundtrip (4MB array)
+

The delta between the ~35 µs raw wire and ~550 µs end-to-end is asyncio scheduling, context-switch between publisher timer and subscriber recv, and Python callback dispatch. Serialization is close to memcpy-bandwidth on large payloads — the OOB transport is pulling its weight.

+

What can be improved

+

Design-level (biggest wins)

+
    +
  1. +

    Latency floor is too high for control loops. ~550 µs mean and ~1.5 ms p99 is dominated by asyncio + zmq.asyncio, not zmq itself. Control topics should be able to opt into a synchronous thread-plus-zmq.Poller receive path targeting <100 µs p99. Async should be the default, not the only option.

    +
  2. +
  3. +

    Discovery is a single REQ/REP chokepoint with stop-the-world semantics. On crashes, stale topic entries are never reclaimed — a crashed publisher's IPC file stays on disk and the registry keeps pointing at a dead socket. Add leases with heartbeats (publisher renews every N seconds; daemon evicts stale entries), or a peer-gossip model where every node beacons presence. The current daemon has no concurrency — one slow client blocks all others.

    +
  4. +
  5. +

    One-publisher-per-topic is a hard limit for robotics. Redundant IMUs, failover, and multi-source fusion are all blocked. The registry should accept N publishers per topic and subscribers should connect() to all of them — ZMQ SUB handles fan-in natively.

    +
  6. +
  7. +

    No backpressure semantics. pub.publish() is NOBLOCK and silently drops on HWM. Subscriber HWM=10 on SUB evicts old messages by default. Robotics needs per-topic QoS profiles similar to DDS:

    +
  8. +
  9. best_effort_latest — camera frames: drop old, keep newest (ZMQ_CONFLATE=1).
  10. +
  11. reliable_queue — commands: block or surface an error.
  12. +
  13. +

    dropping_queue — telemetry: current behavior, but with a drop counter.

    +
  14. +
  15. +

    No liveness or drop detection. A subscriber has no way to know the publisher died. Sequence numbers exist in the header but are never checked for gaps. Automatic gap-counting in Subscriber would be gold for debugging.

    +
  16. +
  17. +

    Callback execution blocks the receive loop. A 10 ms callback accumulates on SUB HWM and drops. Receive, decode, and user-callback execution should be decoupled with a bounded work queue and one or more worker coroutines/threads per subscriber. ROS 2 executors have this distinction for a reason.

    +
  18. +
  19. +

    Local-only transport in practice. Addresses are hardcoded ipc:// paths under /tmp. Multi-host robotics (robot ↔ base-station) needs TCP transport in discovery, NIC selection, and topology-aware addressing.

    +
  20. +
  21. +

    No shared memory for huge payloads. At 9 GB/s on 4 MB arrays, every subscriber gets a fresh copy. For multi-subscriber camera or LiDAR fan-out, a shared-memory transport (posix shm + ring buffer + zmq for control-plane notifications) would give true zero-copy.

    +
  22. +
+

Code-level issues

+
    +
  1. +

    publisher.py:91-95zmq.Context(self._context) creates a shadowed sync context sharing the async context's io threads. Correct, but subtle. zmq.PUB is not thread-safe — calling pub.publish() from multiple asyncio tasks on the same socket is undefined. Needs docs or a lock.

    +
  2. +
  3. +

    publisher.py:117-118 — the publisher unlinks any existing socket file on startup. If two publishers on the same host use the same node name + topic, the second silently steals the socket. Should fail loudly.

    +
  4. +
  5. +

    subscriber.py:155-160 — fingerprint mismatch logs a warning and proceeds anyway. That is a silent-data-corruption path. Should refuse to connect.

    +
  6. +
  7. +

    messages/base.py:109-129_sequence_counter is class-level, shared across every Publisher instance of that message type in the process. Two publishers of ArrayMessage interleave sequences — breaking per-topic drop detection. Move it onto the Publisher.

    +
  8. +
  9. +

    utils/hashing.py:34-38field.type is a string with PEP 563 and a real type otherwise; the resulting fingerprint differs across import environments. Use typing.get_type_hints(cls) consistently.

    +
  10. +
  11. +

    discovery/client.py:78-101retries=1 default means zero retries (loop runs once). Fencepost bug.

    +
  12. +
  13. +

    core/executor.py:119-147RateExecutor has both await asyncio.sleep(0) inside the loop and await asyncio.sleep(max(0, dt)) at the bottom. The first is redundant and creates unnecessary wakeups. Catch-up logic silently eats dropped ticks; control loops often need to know.

    +
  14. +
  15. +

    discovery/daemon.py:87 — RCVTIMEO=1s means Ctrl-C takes up to 1s to take effect and request throughput is throttled. A zmq.Poller with a shutdown PAIR socket gives clean immediate shutdown.

    +
  16. +
  17. +

    messages/standard.py:146-150ImageMessage.__post_init__ auto-fill is non-idempotent across deserialization round-trips. Minor.

    +
  18. +
  19. +

    discovery/daemon.py:168-177 — same-publisher re-registration is allowed; if its IPC path changed, existing subscribers are never told. Needs a lease or a "changed" notification.

    +
  20. +
  21. +

    No CI test for cross-process fingerprint stability. Given how much safety rides on fingerprints, every standard message type deserves a stored golden fingerprint asserted in CI.

    +
  22. +
  23. +

    from_bytes vs from_frames asymmetry is a trap. Message.decode(bytes) only handles the inline path. If anyone captures bytes from the wire (the multipart path) and calls decode(), it will fail silently. Unify the paths or rename decode.

    +
  24. +
  25. +

    No async publish. send_multipart briefly blocks on HWM/context switch; inside an async timer callback this is a hidden blocking call. An async publish variant would help.

    +
  26. +
+

Schema evolution

+
    +
  1. No optional fields, no versioning. For long-lived robotics deployments, add:
      +
    • field defaults (so fingerprints tolerate missing trailing fields on decode),
    • +
    • an msg_schema_version: int = 1 convention,
    • +
    • eventually, a real wire schema (FlatBuffers, Cap'n Proto, or generated-from-.fbs dataclasses).
    • +
    +
  2. +
+

Summary

+

Cortex is a well-built, honest small-system IPC library. The serialization is genuinely fast — hitting memcpy-bandwidth on 4 MB arrays with zero-copy OOB frames. The latency floor (~550 µs p50, ~1.5 ms p99) is limited by asyncio, not zmq. The discovery, QoS, liveness, and single-host assumptions are the real blockers for using this as robotics middleware.

+

Recommended path if adopting Cortex for robotics:

+
    +
  1. Add per-topic QoS profiles with drop counters (1-2 days).
  2. +
  3. Add a synchronous-threaded subscriber option for low-latency control (1 day).
  4. +
  5. Add heartbeats/leases and multi-publisher support to discovery (3-5 days).
  6. +
  7. Add TCP transport and host-aware discovery (2-3 days).
  8. +
  9. Then consider shared memory and schema evolution.
  10. +
+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/gen_ref_pages.py b/docs/site/gen_ref_pages.py new file mode 100644 index 0000000..a6c4f5a --- /dev/null +++ b/docs/site/gen_ref_pages.py @@ -0,0 +1,47 @@ +"""Generate one API reference page per module under ``src/cortex/``. + +Executed by ``mkdocs-gen-files`` during the build. Emits: + +- ``reference//.md`` for every non-dunder module, +- ``reference//index.md`` for every ``__init__.py``, +- ``reference/SUMMARY.md`` consumed by ``mkdocs-literate-nav``. + +Keeping this generated means adding a new module needs zero doc edits. +""" + +from pathlib import Path + +import mkdocs_gen_files + +# This script lives at ``docs/gen_ref_pages.py`` and is executed by +# mkdocs-gen-files with the mkdocs.yml directory as cwd. Anchor to the +# repo root so the generator finds ``src/cortex`` regardless of cwd. +REPO_ROOT = Path(__file__).resolve().parent.parent +SRC_ROOT = REPO_ROOT / "src" +PACKAGE = "cortex" + +nav = mkdocs_gen_files.Nav() + +for path in sorted((SRC_ROOT / PACKAGE).rglob("*.py")): + module_path = path.relative_to(SRC_ROOT).with_suffix("") + doc_path = Path("reference", *module_path.parts[1:]).with_suffix(".md") + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav_parts = parts[1:] if parts[1:] else ("cortex",) + nav[nav_parts] = doc_path.relative_to("reference").as_posix() + + identifier = ".".join(parts) if parts else PACKAGE + with mkdocs_gen_files.open(doc_path, "w") as f: + f.write(f"# `{identifier}`\n\n") + f.write(f"::: {identifier}\n") + + mkdocs_gen_files.set_edit_path(doc_path, path.relative_to(REPO_ROOT)) + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as f: + f.writelines(nav.build_literate_nav()) diff --git a/docs/site/getting-started/discovery-daemon/index.html b/docs/site/getting-started/discovery-daemon/index.html new file mode 100644 index 0000000..f81e399 --- /dev/null +++ b/docs/site/getting-started/discovery-daemon/index.html @@ -0,0 +1,1896 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Running the Discovery Daemon - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Running the Discovery Daemon

+

The discovery daemon is a lightweight REP service that maintains the registry +of active topics. Publishers register on startup; subscribers look up the +endpoint and connect directly.

+

Start

+
+
+
+
cortex-discovery
+
+
+
+
python -m cortex.discovery.daemon
+
+
+
+
/etc/systemd/system/cortex-discovery.service
[Unit]
+Description=Cortex discovery daemon
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/env cortex-discovery
+Restart=on-failure
+RuntimeDirectory=cortex
+
+[Install]
+WantedBy=multi-user.target
+
+
+
+
+

Command-line options

+ + + + + + + + + + + + + + + + + + + + +
FlagDefaultDescription
--addressipc:///tmp/cortex/discovery.sockZMQ endpoint to bind
--log-levelINFODEBUG / INFO / WARNING / ERROR
+

Lifecycle

+
stateDiagram-v2
+    [*] --> Starting: bind REP socket
+    Starting --> Running: socket ready
+    Running --> Running: handle REGISTER / LOOKUP / LIST / UNREGISTER
+    Running --> Stopping: SIGINT or SHUTDOWN command
+    Stopping --> [*]: close socket, unlink ipc file
+

Troubleshooting

+
+
"Address already in use"
+
Another daemon (or a stale socket file) is holding the path. +rm /tmp/cortex/discovery.sock and restart.
+
Subscribers time out looking up topics
+
Daemon not running, or publisher failed to register. Run with +--log-level DEBUG and watch for REGISTER / LOOKUP lines.
+
Daemon crash leaves stale entries
+
Today, entries are only removed on explicit UNREGISTER. A crashed +publisher's topic stays in the registry pointing at a dead socket. +Restarting the daemon clears all state.
+
+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/getting-started/installation/index.html b/docs/site/getting-started/installation/index.html new file mode 100644 index 0000000..88c5b7c --- /dev/null +++ b/docs/site/getting-started/installation/index.html @@ -0,0 +1,1855 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Installation - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Installation

+

Requirements

+
    +
  • Python 3.10+
  • +
  • Linux or macOS (Windows works but without uvloop)
  • +
  • ZeroMQ shared library (bundled via pyzmq)
  • +
+

Install from source

+
git clone https://github.com/sudoRicheek/cortex.git
+cd cortex
+pip install -e ".[dev]"
+
+

Optional extras

+
+
+
+
pip install -e ".[torch]"
+
+

Enables [TensorMessage][cortex.messages.standard.TensorMessage] and +torch-aware serialization paths.

+
+
+
pip install -e ".[all]"
+
+
+
+
+

Verify

+
import cortex
+print(cortex.__version__)
+
+

If that prints a version string, you're ready. Continue to the +Quickstart.

+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/getting-started/quickstart/index.html b/docs/site/getting-started/quickstart/index.html new file mode 100644 index 0000000..ed191d9 --- /dev/null +++ b/docs/site/getting-started/quickstart/index.html @@ -0,0 +1,1906 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Quickstart - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Quickstart

+

A three-terminal pub/sub loop in under two minutes.

+

1. Start the discovery daemon

+
cortex-discovery
+
+

Leave it running. This is the single service that maps topic names to +IPC endpoints.

+

2. Publisher

+
pub.py
import numpy as np
+import cortex
+from cortex import Node, ArrayMessage
+
+
+class SensorNode(Node):
+    def __init__(self):
+        super().__init__("sensor")
+        self.pub = self.create_publisher("/sensor/data", ArrayMessage)
+        self.count = 0
+        self.create_timer(0.1, self.tick)  # 10 Hz
+
+    async def tick(self):
+        data = np.random.randn(64, 64).astype("float32")
+        self.pub.publish(ArrayMessage(data=data, name=f"frame_{self.count}"))
+        self.count += 1
+
+
+async def main():
+    node = SensorNode()
+    try:
+        await node.run()
+    finally:
+        await node.close()
+
+
+if __name__ == "__main__":
+    cortex.run(main())
+
+
python pub.py
+
+

3. Subscriber

+
sub.py
import cortex
+from cortex import Node, ArrayMessage
+from cortex.messages.base import MessageHeader
+
+
+async def on_data(msg: ArrayMessage, header: MessageHeader):
+    print(f"[{header.sequence}] {msg.name} shape={msg.data.shape}")
+
+
+class ViewerNode(Node):
+    def __init__(self):
+        super().__init__("viewer")
+        self.create_subscriber("/sensor/data", ArrayMessage, callback=on_data)
+
+
+async def main():
+    node = ViewerNode()
+    try:
+        await node.run()
+    finally:
+        await node.close()
+
+
+if __name__ == "__main__":
+    cortex.run(main())
+
+
python sub.py
+
+

What just happened

+
sequenceDiagram
+    participant P as Publisher
+    participant D as Discovery daemon
+    participant S as Subscriber
+
+    P->>D: REGISTER /sensor/data -> ipc:///tmp/cortex/topics/...
+    S->>D: LOOKUP /sensor/data
+    D-->>S: ipc:///tmp/cortex/topics/...
+    S->>P: ZMQ SUB connect + SUBSCRIBE "/sensor/data"
+    loop 10 Hz
+        P->>S: multipart [topic, header, metadata, buffer]
+        S->>S: decode + await on_data(msg, header)
+    end
+

See Concepts → Architecture for the end-to-end +picture, or jump into a custom message tutorial.

+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/guides/benchmarks/index.html b/docs/site/guides/benchmarks/index.html new file mode 100644 index 0000000..ff2a934 --- /dev/null +++ b/docs/site/guides/benchmarks/index.html @@ -0,0 +1,1831 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Benchmarks - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Benchmarks

+

Cortex ships an in-repo benchmark suite at benchmarks/.

+

Run

+
# Terminal 1
+cortex-discovery
+
+# Terminal 2
+python benchmarks/bench_all.py --output results.json
+
+

Individual benchmarks:

+
    +
  • benchmarks/bench_latency.py — one-way publisher→subscriber latency.
  • +
  • benchmarks/bench_throughput.py — messages/sec and MB/sec.
  • +
  • benchmarks/bench_all.py — full matrix with summary and optional JSON dump.
  • +
+

Reading results

+
    +
  • p99 is what matters for real-time-ish workloads; mean can hide jitter.
  • +
  • For array workloads, MB/s approaching memcpy bandwidth is a good sign + that zero-copy transport is working.
  • +
  • Serialization overhead via inproc sockets with copy=False is reported + separately — that isolates the encode/decode path from the network path.
  • +
+

Tips

+
    +
  • Pin publisher and subscriber to separate cores for stable latency numbers.
  • +
  • Disable Turbo-Boost / set CPU governor to performance for reproducible + runs.
  • +
  • Always measure with the discovery daemon also running (it is off the hot + path but can steal a little cache).
  • +
+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/guides/debugging/index.html b/docs/site/guides/debugging/index.html new file mode 100644 index 0000000..2d5cf16 --- /dev/null +++ b/docs/site/guides/debugging/index.html @@ -0,0 +1,1905 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Debugging - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + +

Debugging

+

Subscriber hangs on startup

+

Most likely: the daemon is not running, or the topic name is mistyped. +DiscoveryClient.wait_for_topic_async polls every 500 ms until the topic +appears or the timeout fires.

+
cortex-discovery --log-level DEBUG
+
+

Watch for LOOKUP topic: /x -> NOT FOUND.

+

Publisher "works" but subscriber receives nothing

+

ZMQ PUB drops messages for which no matching SUB is connected yet. If your +publisher starts first and publishes immediately, the first few messages are +lost — this is the classic ZMQ slow-joiner problem.

+

Workarounds:

+
    +
  • Have the publisher wait briefly after bind before publishing the first message.
  • +
  • Have the subscriber wait-for-topic (the default) so it comes up after the + publisher registered.
  • +
+

Stale /tmp/cortex/topics/*.sock files

+

If a publisher exits uncleanly, its IPC socket file remains. Cortex's +Publisher._setup_socket unlinks any existing file at the same path on the +next bind — so restarting the publisher fixes it. Otherwise:

+
rm /tmp/cortex/topics/<stale-socket>.sock
+
+

Daemon state survives restarts — but doesn't

+

The registry is in-memory. Restarting the daemon wipes all state; +publishers do not auto-re-register today. Restart your publishers after +restarting the daemon.

+

Fingerprint mismatch warning

+

If you see +Message type mismatch for /x: expected FooMessage, got BarMessage — +the topic was registered with a different message class. Either rename the +topic or align the classes.

+

Debug logging

+
import logging
+logging.basicConfig(level=logging.DEBUG)
+
+

Cortex uses standard logging. Interesting loggers: cortex.publisher, +cortex.subscriber, cortex.node, cortex.discovery, cortex.discovery.client.

+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/guides/performance-tuning/index.html b/docs/site/guides/performance-tuning/index.html new file mode 100644 index 0000000..be1d753 --- /dev/null +++ b/docs/site/guides/performance-tuning/index.html @@ -0,0 +1,1869 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Performance tuning - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Performance tuning

+

Current measured numbers on the repo's benchmark suite (single workstation):

+ + + + + + + + + + + + + + + + + + + + + + + + + +
WorkloadThroughput / latency
Small payload latencymean 556 µs, p99 1075 µs
1MB array throughput7.7k msg/s, 8.0 GB/s
4MB array throughput2.25k msg/s, 9.4 GB/s
1080p RGB1422 fps, 8.8 GB/s
+

See Benchmarks guide to reproduce.

+

Copy-on-use

+

Decoded NumPy arrays alias the ZMQ frame memory. That is what makes +large-payload throughput close to memcpy bandwidth — but it means:

+
    +
  • If you intend to mutate the array, arr = arr.copy() first.
  • +
  • If you intend to hold the array past the callback, copy it first.
  • +
+

Queue sizing

+

Per-socket HWM defaults to 10. Increase queue_size on high-rate producers +whose subscribers are known to be slow — but remember that ZMQ drops silently +at the HWM.

+

When to prefer the inline path

+

Single tiny messages (primitives only, < 1 KB) see no benefit from multipart. +The inline to_bytes path is still fine there. Publishers always use +multipart today.

+

uvloop

+

Installed by default on Unix. Drops tail latency on high-rate small messages +noticeably. No action needed.

+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/index.html b/docs/site/index.html new file mode 100644 index 0000000..85dde70 --- /dev/null +++ b/docs/site/index.html @@ -0,0 +1,1855 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cortex - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

Cortex

+

A lightweight Python framework for inter-process communication over ZeroMQ.

+

Cortex is a pub/sub layer designed to feel obvious. Nodes publish typed messages on named topics; subscribers receive them via async callbacks. A tiny discovery daemon tells subscribers where to connect. Native support for NumPy arrays and PyTorch tensors keeps robotics- and ML-shaped payloads fast.

+
+
    +
  • +

    Getting started

    +

    Install, start the daemon, publish your first message in under two minutes.

    +
  • +
  • +

    Concepts

    +

    How the wire format, fingerprinting, discovery handshake, and async execution fit together.

    +
  • +
  • +

    Components

    +

    Deep dives into the Messages, Discovery, and Core modules.

    +
  • +
  • +

    API reference

    +

    Auto-generated from the source. Always matches the code on main.

    +
  • +
+
+

Highlights

+
    +
  • Publisher / Subscriber pattern over ZeroMQ PUB/SUB sockets.
  • +
  • Discovery service for automatic topic → endpoint resolution.
  • +
  • IPC transport with zero-copy frames for large NumPy / PyTorch payloads.
  • +
  • 64-bit fingerprint hashing for fast message-type identification.
  • +
  • uvloop-backed async on Linux/macOS for lower tail latency.
  • +
+

Minimal example

+
+
+
+
import numpy as np
+import cortex
+from cortex import Node, ArrayMessage
+
+
+class Cam(Node):
+    def __init__(self):
+        super().__init__("cam")
+        self.pub = self.create_publisher("/cam/frame", ArrayMessage)
+        self.create_timer(1 / 30, self.tick)
+
+    async def tick(self):
+        self.pub.publish(ArrayMessage(data=np.random.randn(480, 640).astype("f4")))
+
+
+cortex.run(Cam().run())
+
+
+
+
import cortex
+from cortex import Node, ArrayMessage
+from cortex.messages.base import MessageHeader
+
+
+async def on_frame(msg: ArrayMessage, header: MessageHeader):
+    print(f"seq={header.sequence} shape={msg.data.shape}")
+
+
+class Viewer(Node):
+    def __init__(self):
+        super().__init__("viewer")
+        self.create_subscriber("/cam/frame", ArrayMessage, callback=on_frame)
+
+
+cortex.run(Viewer().run())
+
+
+
+
+

Project status

+

Cortex targets single-host process graphs today. See design-review.md +and critique.md for an honest account of current limits and the +roadmap toward multi-host robotics use.

+ + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/mkdocs.yml b/docs/site/mkdocs.yml new file mode 100644 index 0000000..76a14fb --- /dev/null +++ b/docs/site/mkdocs.yml @@ -0,0 +1,142 @@ +site_name: Cortex +site_description: Lightweight Python pub/sub over ZeroMQ, for robotics and beyond. +site_url: https://sudoRicheek.github.io/cortex/ +repo_url: https://github.com/sudoRicheek/cortex +repo_name: sudoRicheek/cortex +edit_uri: edit/main/docs/ + +docs_dir: . +site_dir: site +exclude_docs: | + mkdocs.yml + gen_ref_pages.py + site/ + +theme: + name: material + features: + - navigation.tabs + - navigation.sections + - navigation.indexes + - navigation.top + - navigation.footer + - content.code.copy + - content.code.annotate + - content.tabs.link + - search.suggest + - search.highlight + - toc.follow + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + icon: + repo: fontawesome/brands/github + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - def_list + - footnotes + - tables + - toc: + permalink: true + permalink_title: Anchor link + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.snippets + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.emoji: + emoji_index: !!python/name:zensical.extensions.emoji.twemoji + emoji_generator: !!python/name:zensical.extensions.emoji.to_svg + +plugins: + - search + - section-index + - literate-nav: + nav_file: SUMMARY.md + - gen-files: + scripts: + - gen_ref_pages.py + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [../src] + options: + docstring_style: google + show_source: true + show_root_heading: true + show_root_full_path: false + show_object_full_path: false + show_category_heading: true + members_order: source + show_signature_annotations: true + separate_signature: true + show_if_no_docstring: false + heading_level: 2 + filters: + - "!^_" + - "!^__" + +nav: + - Home: index.md + - Getting started: + - getting-started/installation.md + - getting-started/quickstart.md + - getting-started/discovery-daemon.md + - Concepts: + - concepts/architecture.md + - concepts/message-wire-format.md + - concepts/fingerprinting.md + - concepts/discovery-protocol.md + - concepts/transport-and-qos.md + - concepts/async-execution-model.md + - Components: + - components/messages.md + - components/discovery.md + - components/publisher-subscriber.md + - components/node-and-executors.md + - components/serialization.md + - Tutorials: + - tutorials/custom-messages.md + - tutorials/multi-node-system.md + - tutorials/numpy-and-images.md + - tutorials/pytorch-tensors.md + - Guides: + - guides/performance-tuning.md + - guides/benchmarks.md + - guides/debugging.md + - Design notes: + - design-review.md + - critique.md + - API reference: reference/ + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/sudoRicheek/cortex diff --git a/docs/site/objects.inv b/docs/site/objects.inv new file mode 100644 index 0000000000000000000000000000000000000000..f2135e7b8cf342bae3bef9143f22efee22481b76 GIT binary patch literal 127 zcmXAeu?oUK5Cwx}@)HlzTUe&G7eTStA lightweight Python framework for inter-process communication over ZeroMQ.

Cortex is a pub/sub layer designed to feel obvious. Nodes publish typed messages on named topics; subscribers receive them via async callbacks. A tiny discovery daemon tells subscribers where to connect. Native support for NumPy arrays and PyTorch tensors keeps robotics- and ML-shaped payloads fast.

  • Getting started

    Install, start the daemon, publish your first message in under two minutes.

  • Concepts

    How the wire format, fingerprinting, discovery handshake, and async execution fit together.

  • Components

    Deep dives into the Messages, Discovery, and Core modules.

  • API reference

    Auto-generated from the source. Always matches the code on main.

","path":["Cortex"],"tags":[]},{"location":"#highlights","level":2,"title":"Highlights","text":"
  • Publisher / Subscriber pattern over ZeroMQ PUB/SUB sockets.
  • Discovery service for automatic topic → endpoint resolution.
  • IPC transport with zero-copy frames for large NumPy / PyTorch payloads.
  • 64-bit fingerprint hashing for fast message-type identification.
  • uvloop-backed async on Linux/macOS for lower tail latency.
","path":["Cortex"],"tags":[]},{"location":"#minimal-example","level":2,"title":"Minimal example","text":"PublisherSubscriber
import numpy as np\nimport cortex\nfrom cortex import Node, ArrayMessage\n\n\nclass Cam(Node):\n    def __init__(self):\n        super().__init__(\"cam\")\n        self.pub = self.create_publisher(\"/cam/frame\", ArrayMessage)\n        self.create_timer(1 / 30, self.tick)\n\n    async def tick(self):\n        self.pub.publish(ArrayMessage(data=np.random.randn(480, 640).astype(\"f4\")))\n\n\ncortex.run(Cam().run())\n
import cortex\nfrom cortex import Node, ArrayMessage\nfrom cortex.messages.base import MessageHeader\n\n\nasync def on_frame(msg: ArrayMessage, header: MessageHeader):\n    print(f\"seq={header.sequence} shape={msg.data.shape}\")\n\n\nclass Viewer(Node):\n    def __init__(self):\n        super().__init__(\"viewer\")\n        self.create_subscriber(\"/cam/frame\", ArrayMessage, callback=on_frame)\n\n\ncortex.run(Viewer().run())\n
","path":["Cortex"],"tags":[]},{"location":"#project-status","level":2,"title":"Project status","text":"

Cortex targets single-host process graphs today. See design-review.md and critique.md for an honest account of current limits and the roadmap toward multi-host robotics use.

","path":["Cortex"],"tags":[]},{"location":"critique/","level":1,"title":"Cortex Critique","text":"

A bottom-up review of Cortex as it stands today, with a focus on its viability as a communication library for robotics. This complements design-review.md with concrete code-level findings and benchmark observations.

","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#how-cortex-works-bottom-up","level":2,"title":"How Cortex works (bottom-up)","text":"","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#1-fingerprinting-utilshashingpy","level":3,"title":"1. Fingerprinting — utils/hashing.py","text":"

A message class's identity is a 64-bit integer:

fingerprint = SHA-256(f\"{module}.{qualname}|{','.join(sorted('field:type'))}\")[:8]\n
  • Computed lazily and cached in _fingerprint_cache.
  • field.type is a string when from __future__ import annotations is active and a real type otherwise. The fingerprint therefore depends on how the module was imported — fragile for cross-repo use.
  • Field ordering is sorted alphabetically in the fingerprint, but the wire layout uses dataclass declaration order. Two classes could theoretically fingerprint identically but interpret the wire differently.
","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#2-message-base-messagesbasepy","level":3,"title":"2. Message base — messages/base.py","text":"

Each dataclass inheriting Message is auto-registered via __init_subclass__ into MessageType._registry[fingerprint] = cls.

Wire format (multipart transport, what publishers actually use):

Frame 0: topic bytes            (for PUB/SUB filter)\nFrame 1: 24-byte header         (fingerprint u64, timestamp_ns u64, sequence u64, big-endian)\nFrame 2: msgpack of ordered field values with OOB descriptors\nFrame 3..N: raw contiguous array buffers (zero-copy)\n

There is a second, legacy single-blob path (to_bytes / from_bytes) that embeds array bytes inside a single msgpack blob using ExtType. It is retained for Message.decode(...) and tests, but is not what the transport uses.

","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#3-serialization-utilsserializationpy","level":3,"title":"3. Serialization — utils/serialization.py","text":"

Two strategies coexist:

  • _msgpack_default / _msgpack_ext_hook (inline): arrays/tensors get packed as msgpack ExtType inside the single blob. Used by the legacy path.
  • _encode_transport_value / _decode_transport_value (out-of-band): each array/tensor is replaced with a tiny dict {__cortex_oob__: \"numpy\", buffer: i, dtype, shape} and its raw bytes are appended as separate ZMQ frames. Reconstruction uses np.frombuffer(frame.buffer, dtype).reshape(shape) with no copy.

After the March 2026 optimizations: zero-copy decode, schema-ordered values (field names no longer repeated per message), and cached field-name tuples.

","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#4-discovery-discoverydaemonpy-and-discoveryclientpy","level":3,"title":"4. Discovery — discovery/daemon.py and discovery/client.py","text":"

Single-threaded zmq.REP over IPC at ipc:///tmp/cortex/discovery.sock.

  • Registry is a plain dict[str, TopicInfo], enforcing one publisher per topic.
  • RCVTIMEO=1s so the run loop can poll _running for Ctrl-C.
  • Commands: REGISTER, UNREGISTER, LOOKUP, LIST, SHUTDOWN.
  • Request/response payloads are msgpack.
  • Client uses REQ with close-and-recreate on timeout (REQ sockets are stuck after a missed reply).
","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#5-publisher-subscriber-corepublisherpy-coresubscriberpy","level":3,"title":"5. Publisher / Subscriber — core/publisher.py, core/subscriber.py","text":"
  • Publisher: binds a zmq.PUB at ipc:///tmp/cortex/topics/<node>__<topic>.sock, registers via the discovery client, publishes multipart [topic, header, metadata, *buffers] with zmq.NOBLOCK. If the Node hands it an async context, it wraps a sync zmq.Context(self._context) around the same underlying zmq io threads so publishing stays synchronous.
  • Subscriber: uses an async context, looks up the topic (optionally waits), connects zmq.SUB, sets a topic filter, loops via AsyncExecutor doing recv_multipart(copy=False)Message.from_frames.
","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#6-node-executors-corenodepy-coreexecutorpy","level":3,"title":"6. Node + Executors — core/node.py, core/executor.py","text":"

A Node owns a shared zmq.asyncio.Context, plus lists of publishers, subscribers, and timers. Each timer gets a RateExecutor(fn, rate_hz). node.run() creates asyncio tasks for every timer and every callback-subscriber, then asyncio.gather. RateExecutor uses perf_counter plus asyncio.sleep(max(0, next-now)). cortex.run prefers uvloop on Unix.

","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#benchmark-results","level":2,"title":"Benchmark results","text":"

Measured on this machine with the in-repo benchmark suite:

Metric Value Small-payload latency mean 556 µs, p99 1075 µs 64KB latency mean 919 µs, p99 1.4 ms Tiny array throughput 21.8k msg/s 1MB array throughput 7.7k msg/s, 8.0 GB/s 4MB array throughput 2.25k msg/s, 9.4 GB/s 1080p RGB frames 1422 fps, 8.8 GB/s Raw wire+decode (inproc) 35 µs roundtrip (4MB array)

The delta between the ~35 µs raw wire and ~550 µs end-to-end is asyncio scheduling, context-switch between publisher timer and subscriber recv, and Python callback dispatch. Serialization is close to memcpy-bandwidth on large payloads — the OOB transport is pulling its weight.

","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#what-can-be-improved","level":2,"title":"What can be improved","text":"","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#design-level-biggest-wins","level":3,"title":"Design-level (biggest wins)","text":"
  1. Latency floor is too high for control loops. ~550 µs mean and ~1.5 ms p99 is dominated by asyncio + zmq.asyncio, not zmq itself. Control topics should be able to opt into a synchronous thread-plus-zmq.Poller receive path targeting <100 µs p99. Async should be the default, not the only option.

  2. Discovery is a single REQ/REP chokepoint with stop-the-world semantics. On crashes, stale topic entries are never reclaimed — a crashed publisher's IPC file stays on disk and the registry keeps pointing at a dead socket. Add leases with heartbeats (publisher renews every N seconds; daemon evicts stale entries), or a peer-gossip model where every node beacons presence. The current daemon has no concurrency — one slow client blocks all others.

  3. One-publisher-per-topic is a hard limit for robotics. Redundant IMUs, failover, and multi-source fusion are all blocked. The registry should accept N publishers per topic and subscribers should connect() to all of them — ZMQ SUB handles fan-in natively.

  4. No backpressure semantics. pub.publish() is NOBLOCK and silently drops on HWM. Subscriber HWM=10 on SUB evicts old messages by default. Robotics needs per-topic QoS profiles similar to DDS:

  5. best_effort_latest — camera frames: drop old, keep newest (ZMQ_CONFLATE=1).
  6. reliable_queue — commands: block or surface an error.
  7. dropping_queue — telemetry: current behavior, but with a drop counter.

  8. No liveness or drop detection. A subscriber has no way to know the publisher died. Sequence numbers exist in the header but are never checked for gaps. Automatic gap-counting in Subscriber would be gold for debugging.

  9. Callback execution blocks the receive loop. A 10 ms callback accumulates on SUB HWM and drops. Receive, decode, and user-callback execution should be decoupled with a bounded work queue and one or more worker coroutines/threads per subscriber. ROS 2 executors have this distinction for a reason.

  10. Local-only transport in practice. Addresses are hardcoded ipc:// paths under /tmp. Multi-host robotics (robot ↔ base-station) needs TCP transport in discovery, NIC selection, and topology-aware addressing.

  11. No shared memory for huge payloads. At 9 GB/s on 4 MB arrays, every subscriber gets a fresh copy. For multi-subscriber camera or LiDAR fan-out, a shared-memory transport (posix shm + ring buffer + zmq for control-plane notifications) would give true zero-copy.

","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#code-level-issues","level":3,"title":"Code-level issues","text":"
  1. publisher.py:91-95zmq.Context(self._context) creates a shadowed sync context sharing the async context's io threads. Correct, but subtle. zmq.PUB is not thread-safe — calling pub.publish() from multiple asyncio tasks on the same socket is undefined. Needs docs or a lock.

  2. publisher.py:117-118 — the publisher unlinks any existing socket file on startup. If two publishers on the same host use the same node name + topic, the second silently steals the socket. Should fail loudly.

  3. subscriber.py:155-160 — fingerprint mismatch logs a warning and proceeds anyway. That is a silent-data-corruption path. Should refuse to connect.

  4. messages/base.py:109-129_sequence_counter is class-level, shared across every Publisher instance of that message type in the process. Two publishers of ArrayMessage interleave sequences — breaking per-topic drop detection. Move it onto the Publisher.

  5. utils/hashing.py:34-38field.type is a string with PEP 563 and a real type otherwise; the resulting fingerprint differs across import environments. Use typing.get_type_hints(cls) consistently.

  6. discovery/client.py:78-101retries=1 default means zero retries (loop runs once). Fencepost bug.

  7. core/executor.py:119-147RateExecutor has both await asyncio.sleep(0) inside the loop and await asyncio.sleep(max(0, dt)) at the bottom. The first is redundant and creates unnecessary wakeups. Catch-up logic silently eats dropped ticks; control loops often need to know.

  8. discovery/daemon.py:87 — RCVTIMEO=1s means Ctrl-C takes up to 1s to take effect and request throughput is throttled. A zmq.Poller with a shutdown PAIR socket gives clean immediate shutdown.

  9. messages/standard.py:146-150ImageMessage.__post_init__ auto-fill is non-idempotent across deserialization round-trips. Minor.

  10. discovery/daemon.py:168-177 — same-publisher re-registration is allowed; if its IPC path changed, existing subscribers are never told. Needs a lease or a \"changed\" notification.

  11. No CI test for cross-process fingerprint stability. Given how much safety rides on fingerprints, every standard message type deserves a stored golden fingerprint asserted in CI.

  12. from_bytes vs from_frames asymmetry is a trap. Message.decode(bytes) only handles the inline path. If anyone captures bytes from the wire (the multipart path) and calls decode(), it will fail silently. Unify the paths or rename decode.

  13. No async publish. send_multipart briefly blocks on HWM/context switch; inside an async timer callback this is a hidden blocking call. An async publish variant would help.

","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#schema-evolution","level":3,"title":"Schema evolution","text":"
  1. No optional fields, no versioning. For long-lived robotics deployments, add:
    • field defaults (so fingerprints tolerate missing trailing fields on decode),
    • an msg_schema_version: int = 1 convention,
    • eventually, a real wire schema (FlatBuffers, Cap'n Proto, or generated-from-.fbs dataclasses).
","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"critique/#summary","level":2,"title":"Summary","text":"

Cortex is a well-built, honest small-system IPC library. The serialization is genuinely fast — hitting memcpy-bandwidth on 4 MB arrays with zero-copy OOB frames. The latency floor (~550 µs p50, ~1.5 ms p99) is limited by asyncio, not zmq. The discovery, QoS, liveness, and single-host assumptions are the real blockers for using this as robotics middleware.

Recommended path if adopting Cortex for robotics:

  1. Add per-topic QoS profiles with drop counters (1-2 days).
  2. Add a synchronous-threaded subscriber option for low-latency control (1 day).
  3. Add heartbeats/leases and multi-publisher support to discovery (3-5 days).
  4. Add TCP transport and host-aware discovery (2-3 days).
  5. Then consider shared memory and schema evolution.
","path":["Design notes","Cortex Critique"],"tags":[]},{"location":"components/discovery/","level":1,"title":"Discovery","text":"

Source: cortex.discovery.daemon, cortex.discovery.client, cortex.discovery.protocol

Discovery is Cortex's control plane: a single long-lived process that maps topic names to ZMQ endpoints. It sits off the data path — once a subscriber has an endpoint, messages flow publisher → subscriber directly without the daemon's involvement.

","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#moving-parts","level":2,"title":"Moving parts","text":"
flowchart LR\n    subgraph DP[discovery package]\n        PR[protocol.py<br/>DiscoveryRequest /<br/>DiscoveryResponse /<br/>TopicInfo]\n        DM[daemon.py<br/>DiscoveryDaemon<br/>ZMQ REP loop]\n        CL[client.py<br/>DiscoveryClient<br/>ZMQ REQ wrapper]\n    end\n\n    CL -- msgpack REQ --> DM\n    DM -- msgpack REP --> CL\n    PR -.-> DM\n    PR -.-> CL

Everyone agrees on the wire format via protocol.py. The daemon runs a single-threaded REP loop. The client speaks REQ from every publisher and subscriber in the graph.

","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#daemon","level":2,"title":"Daemon","text":"

Implemented in DiscoveryDaemon.

Key behaviors:

  • Binds zmq.REP at ipc:///tmp/cortex/discovery.sock by default.
  • Maintains _topics: dict[str, TopicInfo] — one publisher per topic.
  • RCVTIMEO=1000 on the socket so the loop can check _running for clean Ctrl-C. This also means the daemon is naturally single-request-at-a-time — a slow client blocks all others.
","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#state-transitions","level":3,"title":"State transitions","text":"
stateDiagram-v2\n    [*] --> Starting\n    Starting --> Running: bind OK\n    Running --> Running: REGISTER → insert\n    Running --> Running: LOOKUP → read\n    Running --> Running: UNREGISTER → delete\n    Running --> Running: LIST → snapshot\n    Running --> Stopping: SIGINT / SHUTDOWN\n    Stopping --> [*]: close socket, unlink .sock
","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#registry-semantics","level":3,"title":"Registry semantics","text":"Case Result New topic Insert → OK Same topic, same publisher_node Overwrite → OK (re-registration) Same topic, different publisher_node Reject → ALREADY_EXISTS UNREGISTER missing topic NOT_FOUND","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#client","level":2,"title":"Client","text":"

Implemented in DiscoveryClient.

Thin REQ wrapper around the protocol. Important operational detail: REQ sockets stick after a timeout — they block subsequent sends waiting for a reply that never came. The client handles this by closing and recreating the socket on every timeout (_reconnect). Callers don't see it.

","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#req-timeout-recovery","level":3,"title":"REQ timeout recovery","text":"
flowchart TD\n    S[send request] --> W[wait RCVTIMEO]\n    W -->|reply| OK[return DiscoveryResponse]\n    W -->|timeout| T[zmq.Again]\n    T --> C[close REQ socket]\n    C --> N[create fresh REQ<br/>same endpoint]\n    N -->|attempts < retries| S\n    N -->|exhausted| F[raise TimeoutError]
","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#polling-helpers","level":3,"title":"Polling helpers","text":"
  • lookup_topic(name) — one-shot, returns None on miss.
  • wait_for_topic(name, timeout, poll_interval) — blocking poll loop (time.sleep).
  • wait_for_topic_async(name, timeout, poll_interval) — async poll loop (asyncio.sleep). This is what Subscriber uses when wait_for_topic=True.
","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#protocol","level":2,"title":"Protocol","text":"

Implemented in cortex.discovery.protocol.

Type Purpose DiscoveryCommand REGISTER_TOPIC / UNREGISTER_TOPIC / LOOKUP_TOPIC / LIST_TOPICS / SHUTDOWN DiscoveryStatus OK / NOT_FOUND / ALREADY_EXISTS / ERROR TopicInfo name, address, message_type, fingerprint, publisher_node DiscoveryRequest command + optional topic_info / topic_name DiscoveryResponse status, message, topic_info, topics

All payloads are msgpack. TopicInfo is nested as a packed sub-blob so discovery responses stay flat.

","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#known-limitations","level":2,"title":"Known limitations","text":"

Summarized here, detailed in critique.md:

  • One-publisher-per-topic.
  • No heartbeats or leases — crashed publishers leave stale entries.
  • Single-threaded REP — slow client starves others.
  • retries=1 in the client is a fencepost; effective retries today is zero.
  • Daemon state lost on restart; publishers do not auto-re-register.
","path":["Components","Discovery"],"tags":[]},{"location":"components/discovery/#see-also","level":2,"title":"See also","text":"
  • Concepts → Discovery protocol
  • Getting started → Running the discovery daemon
  • Critique
","path":["Components","Discovery"],"tags":[]},{"location":"components/messages/","level":1,"title":"Messages","text":"

Source: cortex.messages.base, cortex.messages.standard

Messages are just @dataclasses that inherit from Message. Registering with the type system, computing a fingerprint, and (de)serialization all happen automatically.

","path":["Components","Messages"],"tags":[]},{"location":"components/messages/#anatomy-of-a-message","level":2,"title":"Anatomy of a message","text":"
classDiagram\n    class Message {\n        +fingerprint() int\n        +to_bytes() bytes\n        +to_frames() list\n        +from_bytes(data) tuple\n        +from_frames(frames) tuple\n        +decode(bytes) tuple [static]\n        -_build_header()\n        -_field_names() tuple\n        -_field_values() list\n        -_next_sequence() int\n    }\n    class MessageHeader {\n        +fingerprint: int\n        +timestamp_ns: int\n        +sequence: int\n        +to_bytes() bytes\n        +from_bytes(data) MessageHeader\n        +size() int\n    }\n    class MessageType {\n        +register(cls)\n        +get(fingerprint) type\n        +get_all() dict\n    }\n    Message ..> MessageHeader : emits\n    Message ..> MessageType : auto-registers on subclass
","path":["Components","Messages"],"tags":[]},{"location":"components/messages/#defining-a-custom-message","level":2,"title":"Defining a custom message","text":"
from dataclasses import dataclass\nimport numpy as np\nfrom cortex.messages.base import Message\n\n@dataclass\nclass JointTrajectory(Message):\n    timestamp: float\n    positions: np.ndarray   # shape (N,)\n    velocities: np.ndarray  # shape (N,)\n    frame_id: str = \"\"\n

That is the entire contract. The class is registered into MessageType._registry by fingerprint at import time, and gains:

  • JointTrajectory.fingerprint() — 64-bit ID.
  • msg.to_frames() / JointTrajectory.from_frames(frames) — the transport path.
  • msg.to_bytes() / JointTrajectory.from_bytes(data) — the legacy blob path.
  • Message.decode(blob) — class dispatch via fingerprint registry.
","path":["Components","Messages"],"tags":[]},{"location":"components/messages/#sequence-numbering","level":2,"title":"Sequence numbering","text":"

Class-level counter

Message._sequence_counter is shared across all publisher instances of the same message class in the process. Two ArrayMessage publishers interleave sequence numbers. Per-topic gap detection therefore needs a per-publisher counter today; see critique.md § 12.

","path":["Components","Messages"],"tags":[]},{"location":"components/messages/#built-in-messages","level":2,"title":"Built-in messages","text":"Class Use for StringMessage Plain strings IntMessage / FloatMessage Single scalars BytesMessage Opaque binary DictMessage Nested dicts with arrays/tensors ListMessage Mixed-type lists ArrayMessage Single NumPy array + name / frame_id MultiArrayMessage dict[str, np.ndarray] (e.g. points+colors) TensorMessage PyTorch tensor (preserves device/grad) MultiTensorMessage Named tensor bundle (model I/O) ImageMessage Image + encoding + width/height PointCloudMessage XYZ + optional RGB / intensity / normals PoseMessage 6-DoF pose (position + quaternion) TransformMessage 4×4 homogeneous transform TimestampMessage / HeaderMessage ROS-style stamps","path":["Components","Messages"],"tags":[]},{"location":"components/messages/#encode-decode-lifecycle","level":2,"title":"Encode / decode lifecycle","text":"
flowchart LR\n    A[User builds dataclass] --> B[Publisher.publish]\n    B --> C[message.to_frames]\n    C --> D[[ZMQ multipart send]]\n    D --> E[[ZMQ multipart recv]]\n    E --> F[Message.from_frames]\n    F --> G[user callback msg, header]
","path":["Components","Messages"],"tags":[]},{"location":"components/messages/#see-also","level":2,"title":"See also","text":"
  • Concept: message wire format
  • Concept: fingerprinting
  • Tutorial: custom messages
","path":["Components","Messages"],"tags":[]},{"location":"components/node-and-executors/","level":1,"title":"Node & Executors","text":"

Source: cortex.core.node, cortex.core.executor

A Node is the user-facing composition unit: it owns a shared ZMQ async context and a collection of publishers, subscribers, and timers. Executors provide the scheduling primitives that timers and subscriber receive loops run on.

","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#responsibilities","level":2,"title":"Responsibilities","text":"
flowchart TB\n    subgraph NodeResp[Node]\n        CTX[shared zmq.asyncio.Context]\n        PUBS[Publishers dict]\n        SUBS[Subscribers dict]\n        TIMERS[Timers list]\n    end\n\n    NodeResp -- create_publisher --> P[Publisher]\n    NodeResp -- create_subscriber --> S[Subscriber]\n    NodeResp -- create_timer --> RE[RateExecutor]\n    NodeResp -- run / close --> Lifecycle\n\n    P -. uses .-> CTX\n    S -. uses .-> CTX

One node = one process boundary in practice. Nothing stops you running multiple nodes in the same process (asyncio.gather([n.run() for n in nodes]), see examples/multi_node_system.py), but remember they share the same event loop — a slow callback in one still blocks the others.

","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#lifecycle","level":2,"title":"Lifecycle","text":"
stateDiagram-v2\n    [*] --> Constructed: Node(name)\n    Constructed --> Configured: create_publisher/subscriber/timer\n    Configured --> Running: await node.run()\n    Running --> Running: timers fire, callbacks dispatch\n    Running --> Stopping: node.stop() or cancel\n    Stopping --> Closed: await node.close()\n    Closed --> [*]: context terminated
","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#noderun","level":3,"title":"node.run()","text":"

Spawns one asyncio task per timer and one per callback-bearing subscriber, then asyncio.gathers them. Returns when all tasks complete or the node is stopped.

async with Node(\"my_node\") as node:\n    node.create_publisher(\"/x\", IntMessage)\n    node.create_subscriber(\"/y\", IntMessage, callback=on_y)\n    await node.run()   # blocks until cancelled\n# __aexit__ calls close() automatically\n
","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#nodeclose","level":3,"title":"node.close()","text":"

Stops all executors, cancels outstanding tasks, closes every publisher and subscriber (each of which unregisters/unbinds their own socket), and terminates the shared ZMQ context. Idempotent.

","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#executors","level":2,"title":"Executors","text":"

Two flavours, both subclasses of BaseExecutor.

classDiagram\n    class BaseExecutor {\n        <<abstract>>\n        +func: AsyncCallback\n        +start()\n        +stop()\n        +run(*args, **kwargs)\n        #_run_impl()*\n    }\n    class AsyncExecutor {\n        +_run_impl()\n    }\n    class RateExecutor {\n        +rate_hz: float\n        +interval: float\n        +_run_impl()\n    }\n    BaseExecutor <|-- AsyncExecutor\n    BaseExecutor <|-- RateExecutor
","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#asyncexecutor","level":3,"title":"AsyncExecutor","text":"

\"Run this coroutine as fast as possible, yielding between iterations.\"

flowchart LR\n    Start --> Check{running?}\n    Check -- no --> End\n    Check -- yes --> Call[await func]\n    Call -- exception --> Log[log error]\n    Log --> Sleep\n    Call --> Sleep[await sleep 0]\n    Sleep --> Check

Used by Subscriber.run to drive the receive-dispatch loop.

","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#rateexecutor","level":3,"title":"RateExecutor","text":"

\"Run this coroutine at a constant rate, catching up on overruns.\"

flowchart TD\n    Start[next = perf_counter] --> Loop{running?}\n    Loop -- no --> End\n    Loop -- yes --> Now[now = perf_counter]\n    Now --> Due{now >= next?}\n    Due -- yes --> Call[await func]\n    Call --> Advance[next += interval]\n    Advance --> Behind{next < now?}\n    Behind -- yes --> Reset[next = now + interval]\n    Behind -- no --> Wait\n    Reset --> Wait\n    Due -- no --> Wait[await sleep next - now]\n    Wait --> Loop

The catch-up branch silently drops ticks — if your 100 Hz callback takes 20 ms once, you do not get two callbacks back-to-back; you skip one tick.

Redundant yield

Today there is an await asyncio.sleep(0) inside the loop and await asyncio.sleep(max(0, dt)) at the bottom. That generates an extra wakeup per tick. See critique § 15.

","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#timer-usage","level":2,"title":"Timer usage","text":"
node.create_timer(1.0 / 30, self.publish_frame)   # 30 Hz\nnode.create_timer(1.0, self.log_stats)            # 1 Hz\n

Timers are plain async functions — no decorator, no magic. They run in the same event loop as subscriber callbacks, so the same head-of-line caveat applies.

","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#shared-zmq-context","level":2,"title":"Shared ZMQ context","text":"

Every publisher and subscriber created through a node reuses the node's zmq.asyncio.Context. This means:

  • Socket creation is cheap.
  • io threads are shared across all sockets in the node.
  • Terminating the node's context cleanly shuts down all its sockets.

Do not create your own context inside callbacks; you'll leak resources and defeat the shared-io-thread optimization.

","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#minimal-complete-node","level":2,"title":"Minimal complete node","text":"
from dataclasses import dataclass\nimport numpy as np\nimport cortex\nfrom cortex import Node, Message\nfrom cortex.messages.base import MessageHeader\n\n\n@dataclass\nclass Ping(Message):\n    payload: np.ndarray\n    counter: int\n\n\nclass Echo(Node):\n    def __init__(self):\n        super().__init__(\"echo\")\n        self.pub = self.create_publisher(\"/pong\", Ping)\n        self.create_subscriber(\"/ping\", Ping, callback=self.on_ping)\n        self._n = 0\n\n    async def on_ping(self, msg: Ping, header: MessageHeader):\n        self._n += 1\n        self.pub.publish(Ping(payload=msg.payload, counter=self._n))\n\n\nasync def main():\n    async with Echo() as node:\n        await node.run()\n\n\nif __name__ == \"__main__\":\n    cortex.run(main())\n
","path":["Components","Node & Executors"],"tags":[]},{"location":"components/node-and-executors/#see-also","level":2,"title":"See also","text":"
  • cortex.core.node
  • cortex.core.executor
  • Concepts → Async execution model
  • Components → Publisher & Subscriber
","path":["Components","Node & Executors"],"tags":[]},{"location":"components/publisher-subscriber/","level":1,"title":"Publisher & Subscriber","text":"

Source: cortex.core.publisher, cortex.core.subscriber

The data-plane workhorses. A Publisher binds a ZMQ PUB socket and registers with discovery; a Subscriber looks up the endpoint, connects a SUB socket, and drives an async receive loop. Discovery is consulted once per topic on startup — it is not on the hot path.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#relationship-to-the-rest-of-the-stack","level":2,"title":"Relationship to the rest of the stack","text":"
flowchart LR\n    Node -.owns.-> P[Publisher]\n    Node -.owns.-> S[Subscriber]\n    P -- register --> DC1[DiscoveryClient]\n    S -- lookup --> DC2[DiscoveryClient]\n    P -- send_multipart --> Sock1[(zmq.PUB<br/>IPC)]\n    Sock1 -. IPC .-> Sock2[(zmq.SUB)]\n    S -- recv_multipart --> Sock2\n    M[Message] -- to_frames --> P\n    S -- from_frames --> M
","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#publisher","level":2,"title":"Publisher","text":"","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#construction","level":3,"title":"Construction","text":"

Always create via Node.create_publisher — direct construction works but skips the shared ZMQ context reuse and the node-level registration bookkeeping.

pub = node.create_publisher(\n    topic_name=\"/camera/image\",   # must start with \"/\"\n    message_type=ImageMessage,    # fingerprint is taken from this class\n    queue_size=100,               # SNDHWM; drops under backpressure\n)\n
","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#startup-sequence","level":3,"title":"Startup sequence","text":"
sequenceDiagram\n    autonumber\n    participant U as User\n    participant Pub as Publisher\n    participant FS as /tmp/cortex/topics/\n    participant ZMQ as zmq.PUB\n    participant D as Discovery daemon\n\n    U->>Pub: __init__(topic, msg_cls, ...)\n    Pub->>Pub: address = generate_ipc_address(topic, node)\n    Pub->>FS: mkdir -p; unlink stale .sock\n    Pub->>ZMQ: socket(PUB); setsockopt HWM/LINGER; bind(address)\n    Pub->>D: REGISTER TopicInfo{name, address, fingerprint, node}\n    D-->>Pub: OK / ALREADY_EXISTS\n    Note over Pub: ready; user can publish()

Two things worth calling out:

  1. The IPC address is derived deterministically from node_name and topic_name via generate_ipc_address: ipc:///tmp/cortex/topics/<node>__<topic-with-slashes-as-underscores>.sock.
  2. _setup_socket unlinks any existing file at that path before binding. That protects against crash-leftover sockets, but also means two publishers configured with the same node_name + topic_name in the same process tree will silently stomp each other — see critique § 10.
","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#publish-path","level":3,"title":"Publish path","text":"
flowchart LR\n    Msg[Message dataclass] --> H[build MessageHeader<br/>fp, ts, seq]\n    Msg --> V[serialize_message_frames<br/>values]\n    H --> F[[frame 1: header 24B]]\n    V --> F2[[frame 2: msgpack metadata]]\n    V --> FN[[frames 3..N: array buffers]]\n    T[[frame 0: topic bytes]]\n    F --> Send\n    F2 --> Send\n    FN --> Send\n    T --> Send\n    Send[send_multipart NOBLOCK] -->|success| Pub[publish count++]\n    Send -->|zmq.Again| Drop[return False]

publish() is synchronous and returns a boolean:

  • True — handed to ZMQ successfully.
  • Falsezmq.Again, queue full, message dropped.

Any other exception is logged and swallowed; publish still returns False. For robotics code this \"fire and forget\" is intentional — the caller decides whether to retry based on the return value and the topic's role.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#async-context-quirk","level":3,"title":"Async context quirk","text":"

Node owns a zmq.asyncio.Context. The Publisher constructor detects this and wraps a sync zmq.Context around the same underlying io threads:

if isinstance(self._context, zmq.asyncio.Context):\n    self._context: zmq.Context = zmq.Context(self._context)\n

This keeps publish() a normal function call instead of forcing every publish to be awaited. It is the right performance choice, but it has consequences:

zmq.PUB is not thread-safe

Do not call publish() on the same Publisher from multiple threads (or multiple asyncio tasks that could race on send_multipart). Serialize per-publisher calls yourself if you fan out work.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#lifecycle-and-cleanup","level":3,"title":"Lifecycle and cleanup","text":"
stateDiagram-v2\n    [*] --> Bound: bind + register\n    Bound --> Publishing: publish() calls\n    Publishing --> Publishing: more messages\n    Publishing --> Closed: close()\n    Bound --> Closed: close()\n    Closed --> [*]: unregister,<br/>unlink .sock file

Publisher.close() is best-effort: it unregisters from the daemon (silently tolerates a dead daemon), closes the socket, and removes the IPC file. Exceptions from any one step do not block the others.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#statistics","level":3,"title":"Statistics","text":"

publisher.publish_count, publisher.last_publish_time, and publisher.is_registered are exposed for instrumentation. They update on the hot path with no locking — read them from the same task that calls publish() for deterministic numbers.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#subscriber","level":2,"title":"Subscriber","text":"","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#construction_1","level":3,"title":"Construction","text":"
sub = node.create_subscriber(\n    topic_name=\"/camera/image\",\n    message_type=ImageMessage,\n    callback=on_image,          # async def callback(msg, header)\n    queue_size=10,              # RCVHWM\n    wait_for_topic=True,        # poll until topic appears\n    topic_timeout=30.0,         # abort wait after N seconds\n)\n

If callback is None, the subscriber is passive — call await sub.receive() manually. With a callback, Node.run() will drive the receive loop.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#startup-sequence_1","level":3,"title":"Startup sequence","text":"
sequenceDiagram\n    autonumber\n    participant U as User\n    participant S as Subscriber\n    participant D as DiscoveryClient\n    participant Pub as publisher IPC\n\n    U->>S: __init__(...)\n    S->>D: lookup_topic(name)  # non-blocking\n    alt found immediately\n        D-->>S: TopicInfo\n        S->>S: verify fingerprint\n        S->>Pub: SUB connect + SUBSCRIBE topic\n        Note over S: is_connected = True\n    else not found\n        D-->>S: None\n        Note over S: defer; retry in run()\n    end\n\n    U->>S: node.run() schedules sub.run()\n    S->>D: wait_for_topic_async(name, timeout)\n    D-->>S: TopicInfo\n    S->>Pub: SUB connect + SUBSCRIBE topic

The constructor tries a non-blocking lookup first so that when a publisher is already up, no polling is needed. The polling fallback only kicks in inside sub.run() via wait_for_topic_async.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#receive-loop","level":3,"title":"Receive loop","text":"
flowchart LR\n    Loop{{AsyncExecutor}} --> Recv[await recv_multipart copy=False]\n    Recv --> Frames[frames = topic, header, metadata, *buffers]\n    Frames --> Decode[Message.from_frames frames 1..]\n    Decode --> CB[await callback msg, header]\n    CB --> Yield[await asyncio.sleep 0]\n    Yield --> Loop
  • copy=False means each frame is a zmq.Frame — the metadata and array buffers are memoryview-able without a copy. See cortex.utils.serialization.
  • The one-frame fast path (len(payload_frames) == 1) handles legacy publishers still on the single-blob path — it falls back to from_bytes on the single payload buffer.
","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#head-of-line-blocking","level":3,"title":"Head-of-line blocking","text":"

The callback runs inline in the receive loop. A slow callback stalls everything:

gantt\n    title Receive loop when callback is slow\n    dateFormat X\n    axisFormat %L ms\n    section Messages\n    recv m1       :0, 1\n    decode m1     :1, 2\n    callback m1 (slow!) :active, 2, 50\n    recv m2 (queued on HWM) :crit, 50, 51\n    decode m2     :51, 52\n    callback m2   :52, 55

If callbacks do meaningful work, dispatch them to a task or thread pool:

import asyncio\n\nasync def on_image(msg, header):\n    asyncio.create_task(process_in_background(msg, header))\n

Or use a bounded queue + worker pattern. The roadmap item in critique § 6 is to lift this into the framework.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#fingerprint-verification","level":3,"title":"Fingerprint verification","text":"

On connect the subscriber compares its class's fingerprint to the one in the registry entry. Today a mismatch only logs a warning and proceeds anyway — downstream decoding will then fail hard. Treat fingerprint warnings as errors in your code.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#cleanup","level":3,"title":"Cleanup","text":"

Subscriber.close() stops the executor, closes the discovery client and SUB socket, and flips is_connected to False. Safe to call multiple times; errors are suppressed so teardown does not cascade.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#statistics-and-instrumentation","level":2,"title":"Statistics and instrumentation","text":"Property Publisher Subscriber publish_count / receive_count ✓ ✓ last_publish_time / last_receive_time ✓ ✓ is_registered / is_connected ✓ ✓ topic_info

None of these are atomic; treat them as coarse gauges.

","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#common-pitfalls","level":2,"title":"Common pitfalls","text":"Symptom Cause Fix First N messages not received ZMQ \"slow joiner\": SUB not connected yet when PUB started publishing Let subscriber start first, or sleep briefly before first publish Subscriber receives nothing, no errors Topic name mismatch, or forgot to call node.run() Log both sides; run cortex-discovery --log-level DEBUG publish() returns False repeatedly Subscriber can't keep up; SNDHWM reached Increase queue_size, or reduce publish rate Mutating a received array \"corrupts\" later Decoded arrays alias ZMQ frame memory arr = arr.copy() before mutating Two processes stomp each other's socket Same node_name + topic_name Unique node names per process","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/publisher-subscriber/#see-also","level":2,"title":"See also","text":"
  • cortex.core.publisher
  • cortex.core.subscriber
  • Concepts → Async execution model
  • Concepts → Message wire format
  • Guides → Debugging
","path":["Components","Publisher & Subscriber"],"tags":[]},{"location":"components/serialization/","level":1,"title":"Serialization","text":"

Source: cortex.utils.serialization, cortex.utils.hashing

Two encodings live side by side: a multipart / out-of-band path that the transport actually uses, and a single-blob path kept for the legacy Message.to_bytes / decode API and tests. Both support the same Python types; only their frame layout differs.

","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#supported-types","level":2,"title":"Supported types","text":"Type Inline path (to_bytes) OOB path (to_frames) None 1 byte tag msgpack nil int, float, str, bool msgpack PRIMITIVE msgpack bytes tag + length + bytes msgpack bin list, tuple, dict msgpack with ExtType arrays msgpack with OOB descriptors np.ndarray ExtType (inline bytes) OOB descriptor + extra frame torch.Tensor ExtType (inline bytes) OOB descriptor + extra frame","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#the-two-paths-side-by-side","level":2,"title":"The two paths, side by side","text":"OOB multipart (used on the wire)Inline blob (legacy / Message.decode)
flowchart LR\n    V[values] --> E[_encode_transport_value]\n    E --> Meta[msgpack metadata<br/>OOB descriptors for arrays]\n    E --> Bufs[[buffer 0]]\n    E --> Bufs2[[buffer 1]]\n    Meta --> Out[(list of frames)]\n    Bufs --> Out\n    Bufs2 --> Out

The function of interest is serialize_message_frames:

metadata_bytes, [buf0, buf1, ...] = serialize_message_frames(values)\n

Arrays stay contiguous; ZMQ hands the buffer straight to the kernel.

flowchart LR\n    V[values] --> P[msgpack.packb<br/>default=_msgpack_default]\n    P --> Ext[ExtType 1/2 for arrays/tensors<br/>bytes embedded]\n    Ext --> Blob[single bytes blob]

The single blob round-trips through serialize(value)deserialize(data). Useful for persisting to disk, caches, or when you need a self-contained payload without tracking extra buffers.

","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#oob-descriptors","level":2,"title":"OOB descriptors","text":"

An out-of-band descriptor is a small dict that takes the place of the array inside the msgpack metadata:

# numpy\n{\"__cortex_oob__\": \"numpy\", \"buffer\": 0, \"dtype\": \"<f4\", \"shape\": [480, 640, 3]}\n\n# torch\n{\"__cortex_oob__\": \"torch\", \"buffer\": 1, \"dtype\": \"<f4\",\n \"shape\": [1, 3, 224, 224], \"device\": \"cuda:0\", \"requires_grad\": True}\n

The buffer index refers into the ZMQ frames that follow the metadata. Nested structures (dict of arrays, list of tensors, etc.) are walked recursively by _encode_transport_value / _decode_transport_value.

","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#zero-copy-on-the-decode-side","level":2,"title":"Zero-copy on the decode side","text":"
sequenceDiagram\n    participant Sub as Subscriber\n    participant ZMQ as zmq.Frame\n    participant MV as memoryview\n    participant NP as np.ndarray\n\n    Sub->>ZMQ: recv_multipart(copy=False)\n    ZMQ-->>Sub: frame with .buffer property\n    Sub->>MV: memoryview(frame.buffer)\n    Sub->>NP: np.frombuffer(mv, dtype).reshape(shape)\n    Note over NP: array aliases the ZMQ frame memory

Aliasing caveat

The returned NumPy array is a view over the ZMQ frame buffer. It is safe to read as long as the frame lives, which is at least until your callback returns. If you need to:

  • mutate the array, or
  • keep it past the callback,

call arr = arr.copy() first. This is cheap compared to the savings on the hot path.

","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#pytorch-specifics","level":2,"title":"PyTorch specifics","text":"
  • Tensors are always moved to CPU for transport. Transport frames carry the tensor's CPU bytes plus the original device string.
  • On decode, CUDA tensors are moved back to the original device when CUDA is available; otherwise they stay on CPU.
  • requires_grad is preserved.
","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#fingerprinting","level":2,"title":"Fingerprinting","text":"

Separate but related: compute_fingerprint(cls) computes a 64-bit identity from the module path, class name, and sorted field:type pairs. Cached per-class in _fingerprint_cache. See Concepts → Fingerprinting for the full story.

","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#when-to-use-each-helper","level":2,"title":"When to use each helper","text":"Helper Use when serialize_message_frames You're building a custom transport that speaks multipart deserialize_message_frames Decoding the above serialize(value) / deserialize Persisting a single value to disk / cache serialize_numpy / deserialize_numpy Raw array round-trip without msgpack overhead Message.to_frames / Message.from_frames Anything inside Cortex itself","path":["Components","Serialization"],"tags":[]},{"location":"components/serialization/#see-also","level":2,"title":"See also","text":"
  • Concepts → Message wire format
  • Concepts → Fingerprinting
  • Guides → Performance tuning
","path":["Components","Serialization"],"tags":[]},{"location":"concepts/architecture/","level":1,"title":"Architecture","text":"

Cortex has three moving parts: the discovery daemon, publisher nodes, and subscriber nodes. They coordinate over ZeroMQ — a REQ/REP control plane for discovery and a PUB/SUB data plane for messages.

","path":["Concepts","Architecture"],"tags":[]},{"location":"concepts/architecture/#high-level-view","level":2,"title":"High-level view","text":"
flowchart TB\n    subgraph CP[Control plane]\n        DD[Discovery daemon<br/><small>ipc:///tmp/cortex/discovery.sock</small>]\n    end\n\n    subgraph DP[Data plane]\n        direction LR\n        P[Publisher node] -- \"PUB / SUB (IPC)\" --> S[Subscriber node]\n    end\n\n    P -- REGISTER --> DD\n    S -- LOOKUP --> DD\n    DD -- TopicInfo --> S\n\n    classDef daemon fill:#6366f1,stroke:#312e81,color:#fff\n    classDef node fill:#0ea5e9,stroke:#0369a1,color:#fff\n    class DD daemon\n    class P,S node
","path":["Concepts","Architecture"],"tags":[]},{"location":"concepts/architecture/#message-journey","level":2,"title":"Message journey","text":"

Tracing one frame end to end:

sequenceDiagram\n    autonumber\n    participant User as User code\n    participant Pub as Publisher\n    participant Sock as ZMQ PUB socket\n    participant Net as IPC\n    participant SSock as ZMQ SUB socket\n    participant Sub as Subscriber\n    participant CB as async callback\n\n    User->>Pub: publish(Message)\n    Pub->>Pub: build header (fingerprint, ts, seq)\n    Pub->>Pub: encode field values + OOB buffers\n    Pub->>Sock: send_multipart([topic, header, metadata, *buffers])\n    Sock->>Net: zero-copy handoff\n    Net->>SSock: frames delivered\n    SSock->>Sub: recv_multipart(copy=False)\n    Sub->>Sub: Message.from_frames(...)\n    Sub->>CB: await callback(msg, header)

Key invariant: array buffers ride as separate ZMQ frames, not inline in the metadata. See Message wire format.

","path":["Concepts","Architecture"],"tags":[]},{"location":"concepts/architecture/#process-layout","level":2,"title":"Process layout","text":"
flowchart LR\n    subgraph P1[Process: sensor]\n        N1[Node<br/>shared zmq.asyncio.Context]\n        PUB1[Publisher /sensor/a]\n        PUB2[Publisher /sensor/b]\n        T1[Timer 30 Hz]\n        N1 --> PUB1\n        N1 --> PUB2\n        N1 --> T1\n    end\n\n    subgraph P2[Process: processor]\n        N2[Node]\n        SUB1[Subscriber /sensor/a]\n        SUB2[Subscriber /sensor/b]\n        PUB3[Publisher /processed]\n        N2 --> SUB1\n        N2 --> SUB2\n        N2 --> PUB3\n    end\n\n    PUB1 -.->|IPC| SUB1\n    PUB2 -.->|IPC| SUB2

Each topic gets its own IPC socket under /tmp/cortex/topics/. A single Node shares one zmq.asyncio.Context across all its publishers and subscribers to avoid per-socket io thread overhead.

","path":["Concepts","Architecture"],"tags":[]},{"location":"concepts/architecture/#see-also","level":2,"title":"See also","text":"
  • Message wire format
  • Fingerprinting
  • Discovery protocol
  • Async execution model
","path":["Concepts","Architecture"],"tags":[]},{"location":"concepts/async-execution-model/","level":1,"title":"Async execution model","text":"

Cortex nodes are asyncio-native. One event loop per process drives all publishers, subscribers, and timers for that node. On Linux and macOS, cortex.run prefers uvloop for lower tail latency.

","path":["Concepts","Async execution model"],"tags":[]},{"location":"concepts/async-execution-model/#node-task-graph","level":2,"title":"Node task graph","text":"
flowchart TB\n    Loop(((asyncio event loop)))\n    Loop --> T1[Timer 1<br/>RateExecutor]\n    Loop --> T2[Timer 2<br/>RateExecutor]\n    Loop --> S1[Subscriber 1<br/>AsyncExecutor]\n    Loop --> S2[Subscriber 2<br/>AsyncExecutor]

Node.run() spawns one task per timer (RateExecutor) and one per callback-bearing subscriber (AsyncExecutor). It then asyncio.gathers them until cancelled.

","path":["Concepts","Async execution model"],"tags":[]},{"location":"concepts/async-execution-model/#rateexecutor-cadence","level":2,"title":"RateExecutor cadence","text":"
sequenceDiagram\n    participant L as Event loop\n    participant R as RateExecutor\n    participant CB as callback\n\n    loop every interval\n        L->>R: resume\n        R->>CB: await callback()\n        R->>R: next_exec_time += interval\n        alt fell behind\n            R->>R: next_exec_time = now + interval\n        end\n        R->>L: sleep(next_exec_time - now)\n    end

Catch-up logic silently drops ticks when a callback overruns its period — something to keep in mind for control loops.

","path":["Concepts","Async execution model"],"tags":[]},{"location":"concepts/async-execution-model/#asyncexecutor-receive-loop","level":2,"title":"AsyncExecutor receive loop","text":"
sequenceDiagram\n    participant L as Event loop\n    participant A as AsyncExecutor\n    participant S as SUB socket\n    participant CB as user callback\n\n    loop while running\n        L->>A: resume\n        A->>S: await recv_multipart(copy=False)\n        S-->>A: frames\n        A->>A: decode message\n        A->>CB: await callback(msg, header)\n        A->>L: sleep(0)  (yield)\n    end

Head-of-line blocking

A slow callback stalls the receive loop. Messages pile up on the SUB HWM and get evicted. If you expect variable-latency work, offload callback bodies to asyncio.create_task(...) or a thread pool.

","path":["Concepts","Async execution model"],"tags":[]},{"location":"concepts/async-execution-model/#publish-is-sync-inside-async","level":2,"title":"Publish is sync-inside-async","text":"

The Publisher uses a sync zmq.Context (shadowed onto the node's async context). publish() is a plain function call — no await. This avoids the overhead of the async zmq integration on the send path.

Not thread-safe

A zmq.PUB socket is not safe to call from multiple threads or tasks concurrently. Serialize calls to publish() per publisher.

","path":["Concepts","Async execution model"],"tags":[]},{"location":"concepts/async-execution-model/#uvloop","level":2,"title":"uvloop","text":"

On Unix, importing cortex.run checks for uvloop and uses it if present. Measured impact: modest throughput improvement, meaningful p99 latency reduction on high-rate small messages.

","path":["Concepts","Async execution model"],"tags":[]},{"location":"concepts/async-execution-model/#see-also","level":2,"title":"See also","text":"
  • cortex.core.executor
  • cortex.core.node
  • Components → Node & Executors
","path":["Concepts","Async execution model"],"tags":[]},{"location":"concepts/discovery-protocol/","level":1,"title":"Discovery protocol","text":"

The discovery daemon speaks a tiny msgpack-over-REQ/REP protocol. It is not on the data path — once a subscriber has the endpoint, messages flow publisher → subscriber directly.

","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/discovery-protocol/#commands","level":2,"title":"Commands","text":"Command Payload required Returns REGISTER_TOPIC (1) TopicInfo OK / ALREADY_EXISTS UNREGISTER_TOPIC (2) topic_name or TopicInfo.name OK / NOT_FOUND LOOKUP_TOPIC (3) topic_name OK + TopicInfo / NOT_FOUND LIST_TOPICS (4) — OK + list[TopicInfo] SHUTDOWN (99) — OK; daemon exits

Status codes: OK=0, NOT_FOUND=1, ALREADY_EXISTS=2, ERROR=3.

","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/discovery-protocol/#topicinfo-payload","level":2,"title":"TopicInfo payload","text":"
@dataclass\nclass TopicInfo:\n    name: str              # \"/camera/image\"\n    address: str           # \"ipc:///tmp/cortex/topics/cam__camera_image.sock\"\n    message_type: str      # \"ImageMessage\"\n    fingerprint: int       # 64-bit class fingerprint\n    publisher_node: str    # \"cam\"\n
","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/discovery-protocol/#publisher-register-flow","level":2,"title":"Publisher register flow","text":"
sequenceDiagram\n    autonumber\n    participant P as Publisher\n    participant D as Daemon REP\n\n    P->>P: bind PUB socket on ipc:///tmp/cortex/topics/<node>__<topic>.sock\n    P->>D: REQ → DiscoveryRequest(REGISTER_TOPIC, TopicInfo{...})\n    D->>D: if topic_name absent: insert; else compare publisher_node\n    alt new\n        D-->>P: OK \"Registered topic: /x\"\n    else same publisher re-registering\n        D-->>P: OK (overwrite)\n    else different publisher, same topic\n        D-->>P: ALREADY_EXISTS\n    end
","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/discovery-protocol/#subscriber-lookup-flow","level":2,"title":"Subscriber lookup flow","text":"
sequenceDiagram\n    autonumber\n    participant S as Subscriber\n    participant D as Daemon REP\n    participant P as Publisher\n\n    S->>D: REQ → LOOKUP_TOPIC(\"/x\")\n    alt present\n        D-->>S: OK + TopicInfo\n        S->>P: SUB connect + SUBSCRIBE \"/x\"\n    else missing\n        D-->>S: NOT_FOUND\n        Note over S: if wait_for_topic:<br/>poll every 500 ms until timeout\n        S->>D: retry LOOKUP_TOPIC\n    end

wait_for_topic_async implements the retry loop with asyncio.sleep so the event loop keeps spinning.

","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/discovery-protocol/#req-socket-recovery","level":2,"title":"REQ-socket recovery","text":"

ZMQ REQ sockets enter a bad state after a missed reply — they block further sends. The client detects zmq.Again on timeout and rebuilds the socket:

flowchart TD\n    A[send request] -->|timeout| B[REQ socket stuck]\n    B --> C[close socket]\n    C --> D[recreate socket<br/>same endpoint]\n    D --> E[retry up to retries]

See DiscoveryClient._reconnect.

Fencepost in retries default

retries=1 today executes the loop exactly once — i.e. no retry. Bump to retries=3 in client-side code if you need resilience.

","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/discovery-protocol/#failure-modes-how-cortex-handles-them","level":2,"title":"Failure modes & how Cortex handles them","text":"Scenario Behavior Daemon not running when publisher starts Register fails; publisher still publishes, but no subscriber can find it. Daemon restarts All state lost; publishers must re-register. Current design has no auto-re-register. Publisher crashes Registry keeps stale TopicInfo until someone UNREGISTERs. Two publishers, same topic Second registration rejected with ALREADY_EXISTS. Subscriber looks up before publisher NOT_FOUND; caller may wait_for_topic to poll.

Roadmap items (see critique.md) to address these: leases with heartbeats, multi-publisher support, and notify-on-change.

","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/discovery-protocol/#see-also","level":2,"title":"See also","text":"
  • cortex.discovery.protocol
  • cortex.discovery.client
  • cortex.discovery.daemon
  • Components → Discovery
","path":["Concepts","Discovery protocol"],"tags":[]},{"location":"concepts/fingerprinting/","level":1,"title":"Fingerprinting","text":"

Every message class gets a 64-bit identifier derived from its name and field schema. The fingerprint rides in the header of every published message and does two jobs:

  1. Type dispatch — Message.decode(bytes) looks up the right class in the MessageType registry.
  2. Compatibility check — subscribers verify that the topic they looked up advertises the same fingerprint as the type they were written against.
","path":["Concepts","Fingerprinting"],"tags":[]},{"location":"concepts/fingerprinting/#derivation","level":2,"title":"Derivation","text":"
flowchart LR\n    A[class.__module__ + qualname] --> C[canonical string]\n    B[sorted list of field:type] --> C\n    C --> H[SHA-256]\n    H --> F[first 8 bytes → u64 big-endian]

Pseudocode:

canonical = f\"{cls.__module__}.{cls.__qualname__}|{','.join(sorted('name:type'))}\"\nfingerprint = int.from_bytes(sha256(canonical.encode()).digest()[:8], \"big\")\n

The result is cached per-class in _fingerprint_cache, computed once lazily.

","path":["Concepts","Fingerprinting"],"tags":[]},{"location":"concepts/fingerprinting/#registry","level":2,"title":"Registry","text":"

Message.__init_subclass__ auto-registers every concrete subclass into MessageType._registry keyed by fingerprint. Nothing else to do — decorating your dataclass with @dataclass and inheriting from Message is enough.

from dataclasses import dataclass\nfrom cortex.messages.base import Message\n\n@dataclass\nclass JointState(Message):\n    positions: list[float]\n    velocities: list[float]\n\nprint(hex(JointState.fingerprint()))\n
","path":["Concepts","Fingerprinting"],"tags":[]},{"location":"concepts/fingerprinting/#when-fingerprints-change","level":2,"title":"When fingerprints change","text":"

The fingerprint is not stable across edits that touch:

  • Module path or class name (cortex.messages.standard.ArrayMessage renamed anywhere).
  • Field names.
  • Field type annotations as spelled (see the PEP 563 caveat below).

It is stable across:

  • Adding/removing unrelated classes.
  • Reordering methods.
  • Changing docstrings or default values.
","path":["Concepts","Fingerprinting"],"tags":[]},{"location":"concepts/fingerprinting/#subscriber-check","level":2,"title":"Subscriber check","text":"

On connect, the subscriber compares the topic's advertised fingerprint against the one it computed from its message class:

sequenceDiagram\n    participant S as Subscriber\n    participant D as Discovery daemon\n\n    S->>D: LOOKUP /topic\n    D-->>S: TopicInfo(fingerprint=0xABCD...)\n    S->>S: compare with MyMessage.fingerprint()\n    alt mismatch\n        S-->>S: log warning, continue anyway\n    else match\n        S-->>S: connect and subscribe\n    end

Today: mismatch is a warning, not an error

A fingerprint mismatch currently only logs a warning — see critique.md. Downstream decoding will fail hard. Until that is tightened, prefer to re-exchange type definitions between processes rather than rely on this guard.

","path":["Concepts","Fingerprinting"],"tags":[]},{"location":"concepts/fingerprinting/#pep-563-caveat","level":2,"title":"PEP 563 caveat","text":"

field.type may be a string (under from __future__ import annotations) or a real type otherwise. The canonical string differs in the two cases, so the same class can fingerprint differently across import environments.

When defining messages shared between processes, either use the same import style in both, or rely on the runtime typing.get_type_hints(cls) equivalent once that lands upstream.

","path":["Concepts","Fingerprinting"],"tags":[]},{"location":"concepts/fingerprinting/#see-also","level":2,"title":"See also","text":"
  • cortex.utils.hashingcompute_fingerprint, cache helpers
  • Message wire format
  • Critique § code-level issue 13
","path":["Concepts","Fingerprinting"],"tags":[]},{"location":"concepts/message-wire-format/","level":1,"title":"Message wire format","text":"

Cortex uses ZeroMQ multipart messages. Each published message is a list of frames rather than a single blob. That lets array payloads ride as raw contiguous buffers — no copy into a Python bytes, no re-copy by ZMQ.

","path":["Concepts","Message wire format"],"tags":[]},{"location":"concepts/message-wire-format/#frames-on-the-wire","level":2,"title":"Frames on the wire","text":"
flowchart LR\n    F0[\"Frame 0<br/>topic bytes\"] --> F1\n    F1[\"Frame 1<br/>header (24B)<br/>fingerprint • ts_ns • seq\"] --> F2\n    F2[\"Frame 2<br/>msgpack metadata<br/>(ordered field values)\"] --> F3\n    F3[\"Frame 3..N<br/>raw array buffers<br/>(OOB, zero-copy)\"]
Frame Contents Size 0 Topic name (UTF-8) variable 1 MessageHeader 24 bytes (3 × u64, big-endian) 2 msgpack-packed ordered field values; arrays replaced by OOB descriptors small 3..N np.ndarray.tobytes() / tensor.numpy().tobytes(), contiguous payload-sized","path":["Concepts","Message wire format"],"tags":[]},{"location":"concepts/message-wire-format/#header-layout","level":2,"title":"Header layout","text":"
offset 0        8       16       24\n       |fp u64 |ts u64 |seq u64 |\n        big-endian throughout\n
  • fp — 64-bit message fingerprint, computed from class name and field schema.
  • ts — publisher wall-clock in nanoseconds (time.time_ns()).
  • seq — per-process, per-message-type monotonic counter.
","path":["Concepts","Message wire format"],"tags":[]},{"location":"concepts/message-wire-format/#metadata-frame-2","level":2,"title":"Metadata (Frame 2)","text":"

Field values are packed in declaration order (not by name), so the receiver reconstructs using the dataclass's cached field tuple. This removes per-message field-name encoding.

Arrays and tensors appear in the metadata as small dict stand-ins called OOB descriptors:

{\n  \"__cortex_oob__\": \"numpy\",\n  \"buffer\": 0,\n  \"dtype\": \"<f4\",\n  \"shape\": [480, 640, 3]\n}\n

The buffer index refers into Frames 3..N. The receiver reconstructs:

np.frombuffer(frame.buffer, dtype=np.dtype(desc[\"dtype\"])).reshape(desc[\"shape\"])\n

No copy. The resulting array aliases the ZMQ frame memory — copy it if you need ownership or mutability (see Performance tuning).

","path":["Concepts","Message wire format"],"tags":[]},{"location":"concepts/message-wire-format/#full-encodedecode-flow","level":2,"title":"Full encode/decode flow","text":"
sequenceDiagram\n    participant U as User\n    participant M as Message.to_frames\n    participant S as serialize_message_frames\n    participant E as _encode_transport_value\n    participant Z as ZMQ send_multipart\n\n    U->>M: build header + collect field values\n    M->>S: values in declaration order\n    S->>E: for each value, walk nested dicts/lists\n    E-->>S: scalar stays inline; array → OOB descriptor + buffer appended\n    S-->>M: (metadata_bytes, [buf0, buf1, ...])\n    M-->>Z: [topic, header, metadata, *buffers]
","path":["Concepts","Message wire format"],"tags":[]},{"location":"concepts/message-wire-format/#the-legacy-single-blob-path","level":2,"title":"The legacy single-blob path","text":"

Message.to_bytes() / from_bytes() / Message.decode() still exist. They pack everything into one msgpack blob using ExtType for arrays. That path is retained for tests and opportunistic use; the transport always uses the multipart path above.

Mismatch trap

Bytes captured from the wire cannot be fed to Message.decode() — the wire format is multipart, not a single blob. Use Message.from_frames(frames).

","path":["Concepts","Message wire format"],"tags":[]},{"location":"concepts/message-wire-format/#see-also","level":2,"title":"See also","text":"
  • Fingerprinting
  • cortex.utils.serialization — encoding helpers
  • cortex.messages.baseMessage, MessageHeader
","path":["Concepts","Message wire format"],"tags":[]},{"location":"concepts/transport-and-qos/","level":1,"title":"Transport & QoS","text":"

Stub — deep dive coming in a later pass.

","path":["Concepts","Transport & QoS"],"tags":[]},{"location":"concepts/transport-and-qos/#current-socket-settings","level":2,"title":"Current socket settings","text":"Socket Option Value Notes Publisher PUB SNDHWM 10 (default queue_size) Drops under backpressure Publisher PUB LINGER 0 Immediate close Subscriber SUB RCVHWM 10 Oldest messages evicted when full Subscriber SUB LINGER 0 Daemon REP RCVTIMEO 1000 ms Allows Ctrl-C responsiveness Daemon REP LINGER 0","path":["Concepts","Transport & QoS"],"tags":[]},{"location":"concepts/transport-and-qos/#todays-delivery-semantics","level":2,"title":"Today's delivery semantics","text":"
  • Publisher uses zmq.NOBLOCK: if the send queue is full, the message is silently dropped.
  • Subscriber HWM is a ring buffer: old messages are silently evicted on overflow.

This is fine for best-effort telemetry. It is unsafe for control commands.

","path":["Concepts","Transport & QoS"],"tags":[]},{"location":"concepts/transport-and-qos/#planned-qos-profiles","level":2,"title":"Planned QoS profiles","text":"

Taking inspiration from DDS, three profiles are enough for most robotics use:

  • best_effort_latest — conflate; keep only newest (camera frames).
  • reliable_queue — publisher blocks or errors (control commands).
  • dropping_queue — current behavior with an exposed drop counter (telemetry).

See critique.md § 4 for rationale.

","path":["Concepts","Transport & QoS"],"tags":[]},{"location":"getting-started/discovery-daemon/","level":1,"title":"Running the Discovery Daemon","text":"

The discovery daemon is a lightweight REP service that maintains the registry of active topics. Publishers register on startup; subscribers look up the endpoint and connect directly.

","path":["Getting started","Running the Discovery Daemon"],"tags":[]},{"location":"getting-started/discovery-daemon/#start","level":2,"title":"Start","text":"As a scriptAs a moduleAs a systemd service
cortex-discovery\n
python -m cortex.discovery.daemon\n
/etc/systemd/system/cortex-discovery.service
[Unit]\nDescription=Cortex discovery daemon\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/env cortex-discovery\nRestart=on-failure\nRuntimeDirectory=cortex\n\n[Install]\nWantedBy=multi-user.target\n
","path":["Getting started","Running the Discovery Daemon"],"tags":[]},{"location":"getting-started/discovery-daemon/#command-line-options","level":2,"title":"Command-line options","text":"Flag Default Description --address ipc:///tmp/cortex/discovery.sock ZMQ endpoint to bind --log-level INFO DEBUG / INFO / WARNING / ERROR","path":["Getting started","Running the Discovery Daemon"],"tags":[]},{"location":"getting-started/discovery-daemon/#lifecycle","level":2,"title":"Lifecycle","text":"
stateDiagram-v2\n    [*] --> Starting: bind REP socket\n    Starting --> Running: socket ready\n    Running --> Running: handle REGISTER / LOOKUP / LIST / UNREGISTER\n    Running --> Stopping: SIGINT or SHUTDOWN command\n    Stopping --> [*]: close socket, unlink ipc file
","path":["Getting started","Running the Discovery Daemon"],"tags":[]},{"location":"getting-started/discovery-daemon/#troubleshooting","level":2,"title":"Troubleshooting","text":"\"Address already in use\" Another daemon (or a stale socket file) is holding the path. rm /tmp/cortex/discovery.sock and restart. Subscribers time out looking up topics Daemon not running, or publisher failed to register. Run with --log-level DEBUG and watch for REGISTER / LOOKUP lines. Daemon crash leaves stale entries Today, entries are only removed on explicit UNREGISTER. A crashed publisher's topic stays in the registry pointing at a dead socket. Restarting the daemon clears all state.","path":["Getting started","Running the Discovery Daemon"],"tags":[]},{"location":"getting-started/installation/","level":1,"title":"Installation","text":"","path":["Getting started","Installation"],"tags":[]},{"location":"getting-started/installation/#requirements","level":2,"title":"Requirements","text":"
  • Python 3.10+
  • Linux or macOS (Windows works but without uvloop)
  • ZeroMQ shared library (bundled via pyzmq)
","path":["Getting started","Installation"],"tags":[]},{"location":"getting-started/installation/#install-from-source","level":2,"title":"Install from source","text":"
git clone https://github.com/sudoRicheek/cortex.git\ncd cortex\npip install -e \".[dev]\"\n
","path":["Getting started","Installation"],"tags":[]},{"location":"getting-started/installation/#optional-extras","level":2,"title":"Optional extras","text":"PyTorch supportEverything
pip install -e \".[torch]\"\n

Enables TensorMessage and torch-aware serialization paths.

pip install -e \".[all]\"\n
","path":["Getting started","Installation"],"tags":[]},{"location":"getting-started/installation/#verify","level":2,"title":"Verify","text":"
import cortex\nprint(cortex.__version__)\n

If that prints a version string, you're ready. Continue to the Quickstart.

","path":["Getting started","Installation"],"tags":[]},{"location":"getting-started/quickstart/","level":1,"title":"Quickstart","text":"

A three-terminal pub/sub loop in under two minutes.

","path":["Getting started","Quickstart"],"tags":[]},{"location":"getting-started/quickstart/#1-start-the-discovery-daemon","level":2,"title":"1. Start the discovery daemon","text":"
cortex-discovery\n

Leave it running. This is the single service that maps topic names to IPC endpoints.

","path":["Getting started","Quickstart"],"tags":[]},{"location":"getting-started/quickstart/#2-publisher","level":2,"title":"2. Publisher","text":"pub.py
import numpy as np\nimport cortex\nfrom cortex import Node, ArrayMessage\n\n\nclass SensorNode(Node):\n    def __init__(self):\n        super().__init__(\"sensor\")\n        self.pub = self.create_publisher(\"/sensor/data\", ArrayMessage)\n        self.count = 0\n        self.create_timer(0.1, self.tick)  # 10 Hz\n\n    async def tick(self):\n        data = np.random.randn(64, 64).astype(\"float32\")\n        self.pub.publish(ArrayMessage(data=data, name=f\"frame_{self.count}\"))\n        self.count += 1\n\n\nasync def main():\n    node = SensorNode()\n    try:\n        await node.run()\n    finally:\n        await node.close()\n\n\nif __name__ == \"__main__\":\n    cortex.run(main())\n
python pub.py\n
","path":["Getting started","Quickstart"],"tags":[]},{"location":"getting-started/quickstart/#3-subscriber","level":2,"title":"3. Subscriber","text":"sub.py
import cortex\nfrom cortex import Node, ArrayMessage\nfrom cortex.messages.base import MessageHeader\n\n\nasync def on_data(msg: ArrayMessage, header: MessageHeader):\n    print(f\"[{header.sequence}] {msg.name} shape={msg.data.shape}\")\n\n\nclass ViewerNode(Node):\n    def __init__(self):\n        super().__init__(\"viewer\")\n        self.create_subscriber(\"/sensor/data\", ArrayMessage, callback=on_data)\n\n\nasync def main():\n    node = ViewerNode()\n    try:\n        await node.run()\n    finally:\n        await node.close()\n\n\nif __name__ == \"__main__\":\n    cortex.run(main())\n
python sub.py\n
","path":["Getting started","Quickstart"],"tags":[]},{"location":"getting-started/quickstart/#what-just-happened","level":2,"title":"What just happened","text":"
sequenceDiagram\n    participant P as Publisher\n    participant D as Discovery daemon\n    participant S as Subscriber\n\n    P->>D: REGISTER /sensor/data -> ipc:///tmp/cortex/topics/...\n    S->>D: LOOKUP /sensor/data\n    D-->>S: ipc:///tmp/cortex/topics/...\n    S->>P: ZMQ SUB connect + SUBSCRIBE \"/sensor/data\"\n    loop 10 Hz\n        P->>S: multipart [topic, header, metadata, buffer]\n        S->>S: decode + await on_data(msg, header)\n    end

See Concepts → Architecture for the end-to-end picture, or jump into a custom message tutorial.

","path":["Getting started","Quickstart"],"tags":[]},{"location":"guides/benchmarks/","level":1,"title":"Benchmarks","text":"

Cortex ships an in-repo benchmark suite at benchmarks/.

","path":["Guides","Benchmarks"],"tags":[]},{"location":"guides/benchmarks/#run","level":2,"title":"Run","text":"
# Terminal 1\ncortex-discovery\n\n# Terminal 2\npython benchmarks/bench_all.py --output results.json\n

Individual benchmarks:

  • benchmarks/bench_latency.py — one-way publisher→subscriber latency.
  • benchmarks/bench_throughput.py — messages/sec and MB/sec.
  • benchmarks/bench_all.py — full matrix with summary and optional JSON dump.
","path":["Guides","Benchmarks"],"tags":[]},{"location":"guides/benchmarks/#reading-results","level":2,"title":"Reading results","text":"
  • p99 is what matters for real-time-ish workloads; mean can hide jitter.
  • For array workloads, MB/s approaching memcpy bandwidth is a good sign that zero-copy transport is working.
  • Serialization overhead via inproc sockets with copy=False is reported separately — that isolates the encode/decode path from the network path.
","path":["Guides","Benchmarks"],"tags":[]},{"location":"guides/benchmarks/#tips","level":2,"title":"Tips","text":"
  • Pin publisher and subscriber to separate cores for stable latency numbers.
  • Disable Turbo-Boost / set CPU governor to performance for reproducible runs.
  • Always measure with the discovery daemon also running (it is off the hot path but can steal a little cache).
","path":["Guides","Benchmarks"],"tags":[]},{"location":"guides/debugging/","level":1,"title":"Debugging","text":"","path":["Guides","Debugging"],"tags":[]},{"location":"guides/debugging/#subscriber-hangs-on-startup","level":2,"title":"Subscriber hangs on startup","text":"

Most likely: the daemon is not running, or the topic name is mistyped. DiscoveryClient.wait_for_topic_async polls every 500 ms until the topic appears or the timeout fires.

cortex-discovery --log-level DEBUG\n

Watch for LOOKUP topic: /x -> NOT FOUND.

","path":["Guides","Debugging"],"tags":[]},{"location":"guides/debugging/#publisher-works-but-subscriber-receives-nothing","level":2,"title":"Publisher \"works\" but subscriber receives nothing","text":"

ZMQ PUB drops messages for which no matching SUB is connected yet. If your publisher starts first and publishes immediately, the first few messages are lost — this is the classic ZMQ slow-joiner problem.

Workarounds:

  • Have the publisher wait briefly after bind before publishing the first message.
  • Have the subscriber wait-for-topic (the default) so it comes up after the publisher registered.
","path":["Guides","Debugging"],"tags":[]},{"location":"guides/debugging/#stale-tmpcortextopicssock-files","level":2,"title":"Stale /tmp/cortex/topics/*.sock files","text":"

If a publisher exits uncleanly, its IPC socket file remains. Cortex's Publisher._setup_socket unlinks any existing file at the same path on the next bind — so restarting the publisher fixes it. Otherwise:

rm /tmp/cortex/topics/<stale-socket>.sock\n
","path":["Guides","Debugging"],"tags":[]},{"location":"guides/debugging/#daemon-state-survives-restarts-but-doesnt","level":2,"title":"Daemon state survives restarts — but doesn't","text":"

The registry is in-memory. Restarting the daemon wipes all state; publishers do not auto-re-register today. Restart your publishers after restarting the daemon.

","path":["Guides","Debugging"],"tags":[]},{"location":"guides/debugging/#fingerprint-mismatch-warning","level":2,"title":"Fingerprint mismatch warning","text":"

If you see Message type mismatch for /x: expected FooMessage, got BarMessage — the topic was registered with a different message class. Either rename the topic or align the classes.

","path":["Guides","Debugging"],"tags":[]},{"location":"guides/debugging/#debug-logging","level":2,"title":"Debug logging","text":"
import logging\nlogging.basicConfig(level=logging.DEBUG)\n

Cortex uses standard logging. Interesting loggers: cortex.publisher, cortex.subscriber, cortex.node, cortex.discovery, cortex.discovery.client.

","path":["Guides","Debugging"],"tags":[]},{"location":"guides/performance-tuning/","level":1,"title":"Performance tuning","text":"

Current measured numbers on the repo's benchmark suite (single workstation):

Workload Throughput / latency Small payload latency mean 556 µs, p99 1075 µs 1MB array throughput 7.7k msg/s, 8.0 GB/s 4MB array throughput 2.25k msg/s, 9.4 GB/s 1080p RGB 1422 fps, 8.8 GB/s

See Benchmarks guide to reproduce.

","path":["Guides","Performance tuning"],"tags":[]},{"location":"guides/performance-tuning/#copy-on-use","level":2,"title":"Copy-on-use","text":"

Decoded NumPy arrays alias the ZMQ frame memory. That is what makes large-payload throughput close to memcpy bandwidth — but it means:

  • If you intend to mutate the array, arr = arr.copy() first.
  • If you intend to hold the array past the callback, copy it first.
","path":["Guides","Performance tuning"],"tags":[]},{"location":"guides/performance-tuning/#queue-sizing","level":2,"title":"Queue sizing","text":"

Per-socket HWM defaults to 10. Increase queue_size on high-rate producers whose subscribers are known to be slow — but remember that ZMQ drops silently at the HWM.

","path":["Guides","Performance tuning"],"tags":[]},{"location":"guides/performance-tuning/#when-to-prefer-the-inline-path","level":2,"title":"When to prefer the inline path","text":"

Single tiny messages (primitives only, < 1 KB) see no benefit from multipart. The inline to_bytes path is still fine there. Publishers always use multipart today.

","path":["Guides","Performance tuning"],"tags":[]},{"location":"guides/performance-tuning/#uvloop","level":2,"title":"uvloop","text":"

Installed by default on Unix. Drops tail latency on high-rate small messages noticeably. No action needed.

","path":["Guides","Performance tuning"],"tags":[]},{"location":"tutorials/custom-messages/","level":1,"title":"Custom messages","text":"

A message in Cortex is any dataclass that inherits from Message. Auto-registration, fingerprinting, and (de)serialization are all derived from the dataclass definition — you write the schema once, publishers and subscribers speak the same wire format.

","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/custom-messages/#define","level":2,"title":"Define","text":"messages.py
from dataclasses import dataclass\nimport numpy as np\nfrom cortex.messages.base import Message\n\n\n@dataclass\nclass RobotState(Message):\n    timestamp: float\n    position: np.ndarray      # shape (3,)\n    velocity: np.ndarray      # shape (3,)\n    joint_angles: np.ndarray  # shape (N,)\n    is_moving: bool\n    frame_id: str = \"base_link\"\n

Shared module

Put your message definitions in a module both the publisher and subscriber import. The fingerprint is computed from module.qualname + field names/types; an identical re-declaration in two different modules produces different fingerprints.

","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/custom-messages/#publish","level":2,"title":"Publish","text":"publisher.py
import numpy as np\nimport cortex\nfrom cortex import Node\nfrom messages import RobotState\n\n\nclass StateBroadcaster(Node):\n    def __init__(self):\n        super().__init__(\"robot\")\n        self.pub = self.create_publisher(\"/robot/state\", RobotState)\n        self.create_timer(1 / 100, self.tick)  # 100 Hz\n        self._t0 = 0.0\n\n    async def tick(self):\n        self._t0 += 0.01\n        self.pub.publish(RobotState(\n            timestamp=self._t0,\n            position=np.array([self._t0, 0.0, 0.5], dtype=\"f4\"),\n            velocity=np.array([1.0, 0.0, 0.0], dtype=\"f4\"),\n            joint_angles=np.zeros(7, dtype=\"f4\"),\n            is_moving=True,\n        ))\n\n\nif __name__ == \"__main__\":\n    cortex.run(StateBroadcaster().run())\n
","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/custom-messages/#subscribe","level":2,"title":"Subscribe","text":"subscriber.py
import cortex\nfrom cortex import Node\nfrom cortex.messages.base import MessageHeader\nfrom messages import RobotState   # same import, same fingerprint\n\n\nasync def on_state(msg: RobotState, header: MessageHeader):\n    if header.sequence % 100 == 0:\n        print(f\"t={msg.timestamp:.3f} pos={msg.position}\")\n\n\nclass Monitor(Node):\n    def __init__(self):\n        super().__init__(\"monitor\")\n        self.create_subscriber(\"/robot/state\", RobotState, callback=on_state)\n\n\nif __name__ == \"__main__\":\n    cortex.run(Monitor().run())\n
","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/custom-messages/#how-the-dataclass-becomes-a-wire-message","level":2,"title":"How the dataclass becomes a wire message","text":"
flowchart LR\n    DC[dataclass fields] --> FP[fingerprint]\n    DC --> ORD[declaration order]\n    ORD --> Enc[serialize_message_frames<br/>values in order]\n    Enc --> Meta[metadata frame]\n    Enc --> Bufs[array frames]\n    FP --> Hdr[24-byte header]\n    Hdr --> Wire[(multipart send)]\n    Meta --> Wire\n    Bufs --> Wire

See Concepts → Message wire format for the full picture.

","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/custom-messages/#supported-field-types","level":2,"title":"Supported field types","text":"Field type Notes int / float / bool / str Plain msgpack primitives bytes msgpack bin list[...] / tuple[...] Walked recursively dict[str, Any] Walked recursively; arrays inside are still OOB np.ndarray OOB frame; zero-copy decode torch.Tensor OOB frame; CPU-transported, device restored on decode Optional nested Message Not first-class today — flatten instead","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/custom-messages/#evolution-what-breaks-the-fingerprint","level":2,"title":"Evolution: what breaks the fingerprint","text":"

Changing any of these changes the fingerprint and makes old and new publishers/subscribers incompatible:

  • Renaming the class, its module, or any field
  • Adding a field (even with a default)
  • Removing a field
  • Changing a field's annotation text

Safe to change without breaking:

  • Reordering methods, adding methods
  • Editing docstrings or defaults
  • Changing unrelated classes in the same module

See critique § 22 for the roadmap on first-class schema evolution.

","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/custom-messages/#see-also","level":2,"title":"See also","text":"
  • Concepts → Fingerprinting
  • Components → Messages
  • Tutorials → Multi-node system for custom messages used across multiple nodes
","path":["Tutorials","Custom messages"],"tags":[]},{"location":"tutorials/multi-node-system/","level":1,"title":"Multi-node system","text":"

A walk-through of examples/multi_node_system.py — a sensor → processor → monitor pipeline with custom messages, multiple publishers and subscribers, and periodic status reporting.

","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#topology","level":2,"title":"Topology","text":"
flowchart LR\n    subgraph Sensors[Sensor nodes]\n        S1[\"sensor_lidar<br/>10 Hz\"]\n        S2[\"sensor_camera<br/>10 Hz\"]\n    end\n    Proc[processor]\n    Mon[monitor]\n\n    S1 -- \"/sensor/lidar/raw\" --> Proc\n    S2 -- \"/sensor/camera/raw\" --> Proc\n    Proc -- \"/processed/data\" --> Mon\n    Mon -- \"/system/status<br/>1 Hz\" --> World((world))

Four nodes run in a single Python process, each on the same asyncio event loop via asyncio.gather. Cortex's IPC transport does not care that they share a process — the data still rides through real ZMQ sockets.

","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#message-schema","level":2,"title":"Message schema","text":"

Three custom message types share a module so every node gets the same fingerprints:

@dataclass\nclass SensorReading(Message):\n    sensor_id: str\n    timestamp: float\n    values: np.ndarray\n    temperature: float\n\n@dataclass\nclass ProcessedData(Message):\n    source_sensor: str\n    timestamp: float\n    filtered_values: np.ndarray\n    statistics: dict   # {mean, std, min, max}\n\n@dataclass\nclass SystemStatus(Message):\n    timestamp: float\n    num_sensors: int\n    processing_rate_hz: float\n    total_messages: int\n
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#sensor-node","level":2,"title":"Sensor node","text":"
class SensorNode(Node):\n    def __init__(self, sensor_id: str, publish_rate: float = 10.0):\n        super().__init__(f\"sensor_{sensor_id}\")\n        self.reading_pub = self.create_publisher(\n            f\"/sensor/{sensor_id}/raw\", SensorReading\n        )\n        self.create_timer(1.0 / publish_rate, self._publish_reading)\n\n    async def _publish_reading(self):\n        t = time.time()\n        values = np.sin(np.linspace(0, 2*np.pi, 100) + t) + 0.1*np.random.randn(100)\n        self.reading_pub.publish(SensorReading(\n            sensor_id=self.sensor_id,\n            timestamp=t,\n            values=values.astype(\"f4\"),\n            temperature=25.0 + 0.5*np.random.randn(),\n        ))\n
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#processor-node","level":2,"title":"Processor node","text":"

Subscribes to every sensor and republishes filtered data:

class ProcessorNode(Node):\n    def __init__(self, sensor_ids: list[str]):\n        super().__init__(\"processor\")\n        for sid in sensor_ids:\n            self.create_subscriber(\n                f\"/sensor/{sid}/raw\", SensorReading, callback=self._on_reading\n            )\n        self.processed_pub = self.create_publisher(\"/processed/data\", ProcessedData)\n\n    async def _on_reading(self, msg: SensorReading, header: MessageHeader):\n        filtered = np.convolve(msg.values, np.ones(5) / 5, mode=\"same\")\n        self.processed_pub.publish(ProcessedData(\n            source_sensor=msg.sensor_id,\n            timestamp=msg.timestamp,\n            filtered_values=filtered.astype(\"f4\"),\n            statistics={\n                \"mean\": float(filtered.mean()),\n                \"std\":  float(filtered.std()),\n                \"min\":  float(filtered.min()),\n                \"max\":  float(filtered.max()),\n            },\n        ))\n
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#monitor-node","level":2,"title":"Monitor node","text":"

Tracks throughput and publishes a periodic status message:

class MonitorNode(Node):\n    def __init__(self):\n        super().__init__(\"monitor\")\n        self.create_subscriber(\"/processed/data\", ProcessedData, callback=self._on_processed)\n        self.status_pub = self.create_publisher(\"/system/status\", SystemStatus)\n        self.create_timer(1.0, self._publish_status)\n
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#running-the-whole-graph","level":2,"title":"Running the whole graph","text":"
async def main():\n    sensor_nodes = [SensorNode(sid, publish_rate=10.0) for sid in [\"lidar\", \"camera\"]]\n    processor_node = ProcessorNode([\"lidar\", \"camera\"])\n    monitor_node = MonitorNode()\n    all_nodes = [*sensor_nodes, processor_node, monitor_node]\n\n    await asyncio.sleep(1.0)  # let topics register and subscribers connect\n\n    try:\n        await asyncio.gather(*[n.run() for n in all_nodes])\n    finally:\n        for n in all_nodes:\n            await n.close()\n
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#timeline","level":2,"title":"Timeline","text":"
sequenceDiagram\n    participant L as lidar\n    participant C as camera\n    participant P as processor\n    participant M as monitor\n\n    par at 10 Hz\n        L->>P: SensorReading\n    and\n        C->>P: SensorReading\n    end\n    P->>M: ProcessedData (per reading)\n    Note over M: counts per second\n    M->>M: publish SystemStatus (1 Hz)
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#run-it-yourself","level":2,"title":"Run it yourself","text":"
# Terminal 1\ncortex-discovery\n\n# Terminal 2\npython examples/multi_node_system.py\n

Expected output:

[processor] Sensor lidar: mean=0.012, std=0.708\n[processor] Sensor camera: mean=-0.034, std=0.711\n[monitor] System status: 192 messages, 19.2 Hz processing rate\n
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/multi-node-system/#see-also","level":2,"title":"See also","text":"
  • Tutorials → Custom messages
  • Components → Publisher & Subscriber
  • Components → Node & Executors
","path":["Tutorials","Multi-node system"],"tags":[]},{"location":"tutorials/numpy-and-images/","level":1,"title":"NumPy arrays & images","text":"

Cortex treats NumPy arrays as first-class payloads. Array bytes travel as separate ZMQ frames and are reconstructed with np.frombuffer on the receiver — no intermediate bytes object, no extra copy.

","path":["Tutorials","NumPy arrays & images"],"tags":[]},{"location":"tutorials/numpy-and-images/#pattern-publisher-that-emits-synthetic-frames","level":2,"title":"Pattern: publisher that emits synthetic frames","text":"camera.py
import numpy as np\nimport cortex\nfrom cortex import Node, ArrayMessage\n\n\nclass Camera(Node):\n    def __init__(self):\n        super().__init__(\"camera\")\n        self.pub = self.create_publisher(\"/cam/frame\", ArrayMessage)\n        self.create_timer(1 / 30, self.tick)  # 30 fps\n        self._i = 0\n\n    async def tick(self):\n        # Synthetic 640x480 RGB frame\n        frame = (np.random.rand(480, 640, 3) * 255).astype(\"uint8\")\n        self.pub.publish(ArrayMessage(data=frame, name=f\"f{self._i}\", frame_id=\"camera\"))\n        self._i += 1\n\n\ncortex.run(Camera().run())\n
","path":["Tutorials","NumPy arrays & images"],"tags":[]},{"location":"tutorials/numpy-and-images/#pattern-subscriber-that-processes-frames","level":2,"title":"Pattern: subscriber that processes frames","text":"viewer.py
import numpy as np\nimport cortex\nfrom cortex import Node, ArrayMessage\nfrom cortex.messages.base import MessageHeader\n\n\nasync def on_frame(msg: ArrayMessage, header: MessageHeader):\n    # msg.data aliases the ZMQ frame buffer — copy before mutating\n    frame = msg.data.copy()\n    frame[..., 0] = 0   # zero out red channel\n    print(f\"[{header.sequence}] {msg.name} mean={frame.mean():.1f}\")\n\n\nclass Viewer(Node):\n    def __init__(self):\n        super().__init__(\"viewer\")\n        self.create_subscriber(\"/cam/frame\", ArrayMessage, callback=on_frame)\n\n\ncortex.run(Viewer().run())\n
","path":["Tutorials","NumPy arrays & images"],"tags":[]},{"location":"tutorials/numpy-and-images/#aliasing-rule-of-thumb","level":2,"title":"Aliasing rule of thumb","text":"
flowchart LR\n    A[recv multipart<br/>copy=False] --> B[np.frombuffer view]\n    B --> C{Do you...}\n    C -->|only read inside callback| OK[Use as-is: fastest]\n    C -->|mutate| CP[arr = arr.copy]\n    C -->|keep past callback| CP\n    C -->|pass to another thread| CP\n    CP --> Safe[safe, owned copy]
","path":["Tutorials","NumPy arrays & images"],"tags":[]},{"location":"tutorials/numpy-and-images/#imagemessage-specifics","level":2,"title":"ImageMessage specifics","text":"

ImageMessage carries an encoding string plus optional width / height (auto-filled from the array shape):

from cortex.messages.standard import ImageMessage\n\nmsg = ImageMessage(data=frame, encoding=\"rgb8\")  # width/height filled on __post_init__\npub.publish(msg)\n

Encodings are free-form strings — Cortex does no validation or conversion. Downstream code decides what rgb8 / bgr8 / mono8 mean.

","path":["Tutorials","NumPy arrays & images"],"tags":[]},{"location":"tutorials/numpy-and-images/#zero-copy-footprint","level":2,"title":"Zero-copy footprint","text":"

A 1080p RGB frame is ~6 MB. On the benchmark suite:

  • Allocation on encode: zero (array is passed by view).
  • Allocation on decode: zero (array is a view into the ZMQ frame).
  • Throughput: ~1400 fps on a modern workstation.
","path":["Tutorials","NumPy arrays & images"],"tags":[]},{"location":"tutorials/numpy-and-images/#see-also","level":2,"title":"See also","text":"
  • Concepts → Message wire format
  • Components → Serialization
  • Guides → Performance tuning
","path":["Tutorials","NumPy arrays & images"],"tags":[]},{"location":"tutorials/pytorch-tensors/","level":1,"title":"PyTorch tensors","text":"

TensorMessage lets you pipe tensors between processes with the same zero-copy multipart transport used for NumPy arrays. Device and requires_grad metadata are preserved; the bytes travel via the CPU side of the tensor.

","path":["Tutorials","PyTorch tensors"],"tags":[]},{"location":"tutorials/pytorch-tensors/#publish","level":2,"title":"Publish","text":"inference_producer.py
import torch\nimport cortex\nfrom cortex import Node, TensorMessage\n\n\nclass Inference(Node):\n    def __init__(self):\n        super().__init__(\"inference\")\n        self.pub = self.create_publisher(\"/model/features\", TensorMessage)\n        self.create_timer(1 / 30, self.tick)\n\n    async def tick(self):\n        # Fake feature tensor; could be output of a real model\n        feats = torch.randn(4, 256, 7, 7, device=\"cuda\" if torch.cuda.is_available() else \"cpu\")\n        self.pub.publish(TensorMessage(data=feats, name=\"layer4_feats\"))\n\n\ncortex.run(Inference().run())\n
","path":["Tutorials","PyTorch tensors"],"tags":[]},{"location":"tutorials/pytorch-tensors/#subscribe","level":2,"title":"Subscribe","text":"downstream_consumer.py
import cortex\nfrom cortex import Node, TensorMessage\nfrom cortex.messages.base import MessageHeader\n\n\nasync def on_features(msg: TensorMessage, header: MessageHeader):\n    t = msg.data\n    print(f\"{msg.name}: shape={tuple(t.shape)} device={t.device} grad={t.requires_grad}\")\n\n\nclass Consumer(Node):\n    def __init__(self):\n        super().__init__(\"consumer\")\n        self.create_subscriber(\"/model/features\", TensorMessage, callback=on_features)\n\n\ncortex.run(Consumer().run())\n
","path":["Tutorials","PyTorch tensors"],"tags":[]},{"location":"tutorials/pytorch-tensors/#what-gets-preserved","level":2,"title":"What gets preserved","text":"
flowchart LR\n    A[torch.Tensor<br/>cuda:0, grad=True] --> B[encode: .detach.cpu.numpy<br/>contiguous]\n    B --> C[OOB frame + metadata<br/>device_str, requires_grad, dtype, shape]\n    C -. IPC .-> D[decode: np.frombuffer<br/>torch.from_numpy]\n    D --> E{cuda available?}\n    E -- yes --> F[move to device_str]\n    E -- no --> G[stay on CPU]\n    F --> H[requires_grad_ True if flagged]\n    G --> H
Attribute Transported dtype ✓ exact shapedevice ✓ string; restored on decode if available requires_gradgrad (the actual gradient) ✗ not sent autograd graph ✗ not sent (detach() is implicit)","path":["Tutorials","PyTorch tensors"],"tags":[]},{"location":"tutorials/pytorch-tensors/#multi-tensor-payloads","level":2,"title":"Multi-tensor payloads","text":"

When you need several tensors together — e.g. a model's inputs and outputs — use MultiTensorMessage:

from cortex.messages.standard import MultiTensorMessage\n\nmsg = MultiTensorMessage(tensors={\n    \"image\": image_tensor,\n    \"features\": feat_tensor,\n    \"logits\": logit_tensor,\n})\npub.publish(msg)\n

Each tensor gets its own OOB frame; no bytes are copied into a container.

","path":["Tutorials","PyTorch tensors"],"tags":[]},{"location":"tutorials/pytorch-tensors/#caveats","level":2,"title":"Caveats","text":"

CPU detour is mandatory

Even for two processes on the same GPU, tensors are DMA'd to CPU on send and back to GPU on receive. That is a copy on each side. Cortex does not currently support CUDA IPC — for tight in-process handoffs, prefer a torch.multiprocessing queue or shared CUDA memory.

Install with the torch extra

TensorMessage raises on construction if PyTorch is not installed. Use pip install -e \".[torch]\".

","path":["Tutorials","PyTorch tensors"],"tags":[]},{"location":"tutorials/pytorch-tensors/#see-also","level":2,"title":"See also","text":"
  • Concepts → Message wire format
  • Components → Serialization
  • Tutorials → NumPy arrays & images
","path":["Tutorials","PyTorch tensors"],"tags":[]}]} \ No newline at end of file diff --git a/docs/site/sitemap.xml b/docs/site/sitemap.xml new file mode 100644 index 0000000..e6706d7 --- /dev/null +++ b/docs/site/sitemap.xml @@ -0,0 +1,72 @@ + + + + https://sudoRicheek.github.io/cortex/ + + + https://sudoRicheek.github.io/cortex/getting-started/installation/ + + + https://sudoRicheek.github.io/cortex/getting-started/quickstart/ + + + https://sudoRicheek.github.io/cortex/getting-started/discovery-daemon/ + + + https://sudoRicheek.github.io/cortex/concepts/architecture/ + + + https://sudoRicheek.github.io/cortex/concepts/message-wire-format/ + + + https://sudoRicheek.github.io/cortex/concepts/fingerprinting/ + + + https://sudoRicheek.github.io/cortex/concepts/discovery-protocol/ + + + https://sudoRicheek.github.io/cortex/concepts/transport-and-qos/ + + + https://sudoRicheek.github.io/cortex/concepts/async-execution-model/ + + + https://sudoRicheek.github.io/cortex/components/messages/ + + + https://sudoRicheek.github.io/cortex/components/discovery/ + + + https://sudoRicheek.github.io/cortex/components/publisher-subscriber/ + + + https://sudoRicheek.github.io/cortex/components/node-and-executors/ + + + https://sudoRicheek.github.io/cortex/components/serialization/ + + + https://sudoRicheek.github.io/cortex/tutorials/custom-messages/ + + + https://sudoRicheek.github.io/cortex/tutorials/multi-node-system/ + + + https://sudoRicheek.github.io/cortex/tutorials/numpy-and-images/ + + + https://sudoRicheek.github.io/cortex/tutorials/pytorch-tensors/ + + + https://sudoRicheek.github.io/cortex/guides/performance-tuning/ + + + https://sudoRicheek.github.io/cortex/guides/benchmarks/ + + + https://sudoRicheek.github.io/cortex/guides/debugging/ + + + https://sudoRicheek.github.io/cortex/critique/ + + \ No newline at end of file diff --git a/docs/site/tutorials/custom-messages/index.html b/docs/site/tutorials/custom-messages/index.html new file mode 100644 index 0000000..d8e7511 --- /dev/null +++ b/docs/site/tutorials/custom-messages/index.html @@ -0,0 +1,2039 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Custom messages - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + +

Custom messages

+

A message in Cortex is any dataclass that inherits from +[Message][cortex.messages.base.Message]. Auto-registration, fingerprinting, +and (de)serialization are all derived from the dataclass definition — you +write the schema once, publishers and subscribers speak the same wire format.

+

Define

+
messages.py
from dataclasses import dataclass
+import numpy as np
+from cortex.messages.base import Message
+
+
+@dataclass
+class RobotState(Message):
+    timestamp: float
+    position: np.ndarray      # shape (3,)
+    velocity: np.ndarray      # shape (3,)
+    joint_angles: np.ndarray  # shape (N,)
+    is_moving: bool
+    frame_id: str = "base_link"
+
+
+

Shared module

+

Put your message definitions in a module both the publisher and +subscriber import. The fingerprint is computed from +module.qualname + field names/types; an identical re-declaration in +two different modules produces different fingerprints.

+
+

Publish

+
publisher.py
import numpy as np
+import cortex
+from cortex import Node
+from messages import RobotState
+
+
+class StateBroadcaster(Node):
+    def __init__(self):
+        super().__init__("robot")
+        self.pub = self.create_publisher("/robot/state", RobotState)
+        self.create_timer(1 / 100, self.tick)  # 100 Hz
+        self._t0 = 0.0
+
+    async def tick(self):
+        self._t0 += 0.01
+        self.pub.publish(RobotState(
+            timestamp=self._t0,
+            position=np.array([self._t0, 0.0, 0.5], dtype="f4"),
+            velocity=np.array([1.0, 0.0, 0.0], dtype="f4"),
+            joint_angles=np.zeros(7, dtype="f4"),
+            is_moving=True,
+        ))
+
+
+if __name__ == "__main__":
+    cortex.run(StateBroadcaster().run())
+
+

Subscribe

+
subscriber.py
import cortex
+from cortex import Node
+from cortex.messages.base import MessageHeader
+from messages import RobotState   # same import, same fingerprint
+
+
+async def on_state(msg: RobotState, header: MessageHeader):
+    if header.sequence % 100 == 0:
+        print(f"t={msg.timestamp:.3f} pos={msg.position}")
+
+
+class Monitor(Node):
+    def __init__(self):
+        super().__init__("monitor")
+        self.create_subscriber("/robot/state", RobotState, callback=on_state)
+
+
+if __name__ == "__main__":
+    cortex.run(Monitor().run())
+
+

How the dataclass becomes a wire message

+
flowchart LR
+    DC[dataclass fields] --> FP[fingerprint]
+    DC --> ORD[declaration order]
+    ORD --> Enc[serialize_message_frames<br/>values in order]
+    Enc --> Meta[metadata frame]
+    Enc --> Bufs[array frames]
+    FP --> Hdr[24-byte header]
+    Hdr --> Wire[(multipart send)]
+    Meta --> Wire
+    Bufs --> Wire
+

See Concepts → Message wire format for +the full picture.

+

Supported field types

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Field typeNotes
int / float / bool / strPlain msgpack primitives
bytesmsgpack bin
list[...] / tuple[...]Walked recursively
dict[str, Any]Walked recursively; arrays inside are still OOB
np.ndarrayOOB frame; zero-copy decode
torch.TensorOOB frame; CPU-transported, device restored on decode
Optional nested MessageNot first-class today — flatten instead
+

Evolution: what breaks the fingerprint

+

Changing any of these changes the fingerprint and makes old and new +publishers/subscribers incompatible:

+
    +
  • Renaming the class, its module, or any field
  • +
  • Adding a field (even with a default)
  • +
  • Removing a field
  • +
  • Changing a field's annotation text
  • +
+

Safe to change without breaking:

+
    +
  • Reordering methods, adding methods
  • +
  • Editing docstrings or defaults
  • +
  • Changing unrelated classes in the same module
  • +
+

See critique § 22 for the roadmap on first-class schema +evolution.

+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/tutorials/multi-node-system/index.html b/docs/site/tutorials/multi-node-system/index.html new file mode 100644 index 0000000..a650574 --- /dev/null +++ b/docs/site/tutorials/multi-node-system/index.html @@ -0,0 +1,2077 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Multi-node system - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + + +
+
+ + + +
+ +
+ + + + + + +

Multi-node system

+

A walk-through of examples/multi_node_system.py — +a sensor → processor → monitor pipeline with custom messages, multiple +publishers and subscribers, and periodic status reporting.

+

Topology

+
flowchart LR
+    subgraph Sensors[Sensor nodes]
+        S1["sensor_lidar<br/>10 Hz"]
+        S2["sensor_camera<br/>10 Hz"]
+    end
+    Proc[processor]
+    Mon[monitor]
+
+    S1 -- "/sensor/lidar/raw" --> Proc
+    S2 -- "/sensor/camera/raw" --> Proc
+    Proc -- "/processed/data" --> Mon
+    Mon -- "/system/status<br/>1 Hz" --> World((world))
+

Four nodes run in a single Python process, each on the same asyncio event +loop via asyncio.gather. Cortex's IPC transport does not care that they +share a process — the data still rides through real ZMQ sockets.

+

Message schema

+

Three custom message types share a module so every node gets the same +fingerprints:

+
@dataclass
+class SensorReading(Message):
+    sensor_id: str
+    timestamp: float
+    values: np.ndarray
+    temperature: float
+
+@dataclass
+class ProcessedData(Message):
+    source_sensor: str
+    timestamp: float
+    filtered_values: np.ndarray
+    statistics: dict   # {mean, std, min, max}
+
+@dataclass
+class SystemStatus(Message):
+    timestamp: float
+    num_sensors: int
+    processing_rate_hz: float
+    total_messages: int
+
+

Sensor node

+
class SensorNode(Node):
+    def __init__(self, sensor_id: str, publish_rate: float = 10.0):
+        super().__init__(f"sensor_{sensor_id}")
+        self.reading_pub = self.create_publisher(
+            f"/sensor/{sensor_id}/raw", SensorReading
+        )
+        self.create_timer(1.0 / publish_rate, self._publish_reading)
+
+    async def _publish_reading(self):
+        t = time.time()
+        values = np.sin(np.linspace(0, 2*np.pi, 100) + t) + 0.1*np.random.randn(100)
+        self.reading_pub.publish(SensorReading(
+            sensor_id=self.sensor_id,
+            timestamp=t,
+            values=values.astype("f4"),
+            temperature=25.0 + 0.5*np.random.randn(),
+        ))
+
+

Processor node

+

Subscribes to every sensor and republishes filtered data:

+
class ProcessorNode(Node):
+    def __init__(self, sensor_ids: list[str]):
+        super().__init__("processor")
+        for sid in sensor_ids:
+            self.create_subscriber(
+                f"/sensor/{sid}/raw", SensorReading, callback=self._on_reading
+            )
+        self.processed_pub = self.create_publisher("/processed/data", ProcessedData)
+
+    async def _on_reading(self, msg: SensorReading, header: MessageHeader):
+        filtered = np.convolve(msg.values, np.ones(5) / 5, mode="same")
+        self.processed_pub.publish(ProcessedData(
+            source_sensor=msg.sensor_id,
+            timestamp=msg.timestamp,
+            filtered_values=filtered.astype("f4"),
+            statistics={
+                "mean": float(filtered.mean()),
+                "std":  float(filtered.std()),
+                "min":  float(filtered.min()),
+                "max":  float(filtered.max()),
+            },
+        ))
+
+

Monitor node

+

Tracks throughput and publishes a periodic status message:

+
class MonitorNode(Node):
+    def __init__(self):
+        super().__init__("monitor")
+        self.create_subscriber("/processed/data", ProcessedData, callback=self._on_processed)
+        self.status_pub = self.create_publisher("/system/status", SystemStatus)
+        self.create_timer(1.0, self._publish_status)
+
+

Running the whole graph

+
async def main():
+    sensor_nodes = [SensorNode(sid, publish_rate=10.0) for sid in ["lidar", "camera"]]
+    processor_node = ProcessorNode(["lidar", "camera"])
+    monitor_node = MonitorNode()
+    all_nodes = [*sensor_nodes, processor_node, monitor_node]
+
+    await asyncio.sleep(1.0)  # let topics register and subscribers connect
+
+    try:
+        await asyncio.gather(*[n.run() for n in all_nodes])
+    finally:
+        for n in all_nodes:
+            await n.close()
+
+

Timeline

+
sequenceDiagram
+    participant L as lidar
+    participant C as camera
+    participant P as processor
+    participant M as monitor
+
+    par at 10 Hz
+        L->>P: SensorReading
+    and
+        C->>P: SensorReading
+    end
+    P->>M: ProcessedData (per reading)
+    Note over M: counts per second
+    M->>M: publish SystemStatus (1 Hz)
+

Run it yourself

+
# Terminal 1
+cortex-discovery
+
+# Terminal 2
+python examples/multi_node_system.py
+
+

Expected output:

+
[processor] Sensor lidar: mean=0.012, std=0.708
+[processor] Sensor camera: mean=-0.034, std=0.711
+[monitor] System status: 192 messages, 19.2 Hz processing rate
+
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/tutorials/numpy-and-images/index.html b/docs/site/tutorials/numpy-and-images/index.html new file mode 100644 index 0000000..968fd16 --- /dev/null +++ b/docs/site/tutorials/numpy-and-images/index.html @@ -0,0 +1,1946 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NumPy arrays & images - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + +

NumPy arrays & images

+

Cortex treats NumPy arrays as first-class payloads. Array bytes travel as +separate ZMQ frames and are reconstructed with np.frombuffer on the +receiver — no intermediate bytes object, no extra copy.

+

Pattern: publisher that emits synthetic frames

+
camera.py
import numpy as np
+import cortex
+from cortex import Node, ArrayMessage
+
+
+class Camera(Node):
+    def __init__(self):
+        super().__init__("camera")
+        self.pub = self.create_publisher("/cam/frame", ArrayMessage)
+        self.create_timer(1 / 30, self.tick)  # 30 fps
+        self._i = 0
+
+    async def tick(self):
+        # Synthetic 640x480 RGB frame
+        frame = (np.random.rand(480, 640, 3) * 255).astype("uint8")
+        self.pub.publish(ArrayMessage(data=frame, name=f"f{self._i}", frame_id="camera"))
+        self._i += 1
+
+
+cortex.run(Camera().run())
+
+

Pattern: subscriber that processes frames

+
viewer.py
import numpy as np
+import cortex
+from cortex import Node, ArrayMessage
+from cortex.messages.base import MessageHeader
+
+
+async def on_frame(msg: ArrayMessage, header: MessageHeader):
+    # msg.data aliases the ZMQ frame buffer — copy before mutating
+    frame = msg.data.copy()
+    frame[..., 0] = 0   # zero out red channel
+    print(f"[{header.sequence}] {msg.name} mean={frame.mean():.1f}")
+
+
+class Viewer(Node):
+    def __init__(self):
+        super().__init__("viewer")
+        self.create_subscriber("/cam/frame", ArrayMessage, callback=on_frame)
+
+
+cortex.run(Viewer().run())
+
+

Aliasing rule of thumb

+
flowchart LR
+    A[recv multipart<br/>copy=False] --> B[np.frombuffer view]
+    B --> C{Do you...}
+    C -->|only read inside callback| OK[Use as-is: fastest]
+    C -->|mutate| CP[arr = arr.copy]
+    C -->|keep past callback| CP
+    C -->|pass to another thread| CP
+    CP --> Safe[safe, owned copy]
+

ImageMessage specifics

+

[ImageMessage][cortex.messages.standard.ImageMessage] carries an encoding +string plus optional width / height (auto-filled from the array shape):

+
from cortex.messages.standard import ImageMessage
+
+msg = ImageMessage(data=frame, encoding="rgb8")  # width/height filled on __post_init__
+pub.publish(msg)
+
+

Encodings are free-form strings — Cortex does no validation or conversion. +Downstream code decides what rgb8 / bgr8 / mono8 mean.

+

Zero-copy footprint

+

A 1080p RGB frame is ~6 MB. On the benchmark suite:

+
    +
  • Allocation on encode: zero (array is passed by view).
  • +
  • Allocation on decode: zero (array is a view into the ZMQ frame).
  • +
  • Throughput: ~1400 fps on a modern workstation.
  • +
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/site/tutorials/pytorch-tensors/index.html b/docs/site/tutorials/pytorch-tensors/index.html new file mode 100644 index 0000000..c0855b8 --- /dev/null +++ b/docs/site/tutorials/pytorch-tensors/index.html @@ -0,0 +1,1986 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PyTorch tensors - Cortex + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + +
+
+ + + + + + + + +
+ +
+ + +
+ + + + +
+
+
+ + + +
+ +
+ + + + + + +

PyTorch tensors

+

[TensorMessage][cortex.messages.standard.TensorMessage] lets you pipe +tensors between processes with the same zero-copy multipart transport used +for NumPy arrays. Device and requires_grad metadata are preserved; the +bytes travel via the CPU side of the tensor.

+

Publish

+
inference_producer.py
import torch
+import cortex
+from cortex import Node, TensorMessage
+
+
+class Inference(Node):
+    def __init__(self):
+        super().__init__("inference")
+        self.pub = self.create_publisher("/model/features", TensorMessage)
+        self.create_timer(1 / 30, self.tick)
+
+    async def tick(self):
+        # Fake feature tensor; could be output of a real model
+        feats = torch.randn(4, 256, 7, 7, device="cuda" if torch.cuda.is_available() else "cpu")
+        self.pub.publish(TensorMessage(data=feats, name="layer4_feats"))
+
+
+cortex.run(Inference().run())
+
+

Subscribe

+
downstream_consumer.py
import cortex
+from cortex import Node, TensorMessage
+from cortex.messages.base import MessageHeader
+
+
+async def on_features(msg: TensorMessage, header: MessageHeader):
+    t = msg.data
+    print(f"{msg.name}: shape={tuple(t.shape)} device={t.device} grad={t.requires_grad}")
+
+
+class Consumer(Node):
+    def __init__(self):
+        super().__init__("consumer")
+        self.create_subscriber("/model/features", TensorMessage, callback=on_features)
+
+
+cortex.run(Consumer().run())
+
+

What gets preserved

+
flowchart LR
+    A[torch.Tensor<br/>cuda:0, grad=True] --> B[encode: .detach.cpu.numpy<br/>contiguous]
+    B --> C[OOB frame + metadata<br/>device_str, requires_grad, dtype, shape]
+    C -. IPC .-> D[decode: np.frombuffer<br/>torch.from_numpy]
+    D --> E{cuda available?}
+    E -- yes --> F[move to device_str]
+    E -- no --> G[stay on CPU]
+    F --> H[requires_grad_ True if flagged]
+    G --> H
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeTransported
dtype✓ exact
shape
device✓ string; restored on decode if available
requires_grad
grad (the actual gradient)✗ not sent
autograd graph✗ not sent (detach() is implicit)
+

Multi-tensor payloads

+

When you need several tensors together — e.g. a model's inputs and outputs +— use [MultiTensorMessage][cortex.messages.standard.MultiTensorMessage]:

+
from cortex.messages.standard import MultiTensorMessage
+
+msg = MultiTensorMessage(tensors={
+    "image": image_tensor,
+    "features": feat_tensor,
+    "logits": logit_tensor,
+})
+pub.publish(msg)
+
+

Each tensor gets its own OOB frame; no bytes are copied into a container.

+

Caveats

+
+

CPU detour is mandatory

+

Even for two processes on the same GPU, tensors are DMA'd to CPU on send +and back to GPU on receive. That is a copy on each side. Cortex does not +currently support CUDA IPC — for tight in-process handoffs, prefer a +torch.multiprocessing queue or shared CUDA memory.

+
+
+

Install with the torch extra

+

TensorMessage raises on construction if PyTorch is not installed. Use +pip install -e ".[torch]".

+
+

See also

+ + + + + + + + + + + + + + + + +
+
+ + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/tutorials/custom-messages.md b/docs/tutorials/custom-messages.md new file mode 100644 index 0000000..4c9e280 --- /dev/null +++ b/docs/tutorials/custom-messages.md @@ -0,0 +1,140 @@ +# Custom messages + +A **message** in Cortex is any dataclass that inherits from +[`Message`][cortex.messages.base.Message]. Auto-registration, fingerprinting, +and (de)serialization are all derived from the dataclass definition — you +write the schema once, publishers and subscribers speak the same wire format. + +## Define + +```python title="messages.py" +from dataclasses import dataclass +import numpy as np +from cortex.messages.base import Message + + +@dataclass +class RobotState(Message): + timestamp: float + position: np.ndarray # shape (3,) + velocity: np.ndarray # shape (3,) + joint_angles: np.ndarray # shape (N,) + is_moving: bool + frame_id: str = "base_link" +``` + +!!! tip "Shared module" + Put your message definitions in a module **both** the publisher and + subscriber import. The fingerprint is computed from + `module.qualname` + field names/types; an identical re-declaration in + two different modules produces **different** fingerprints. + +## Publish + +```python title="publisher.py" +import numpy as np +import cortex +from cortex import Node +from messages import RobotState + + +class StateBroadcaster(Node): + def __init__(self): + super().__init__("robot") + self.pub = self.create_publisher("/robot/state", RobotState) + self.create_timer(1 / 100, self.tick) # 100 Hz + self._t0 = 0.0 + + async def tick(self): + self._t0 += 0.01 + self.pub.publish(RobotState( + timestamp=self._t0, + position=np.array([self._t0, 0.0, 0.5], dtype="f4"), + velocity=np.array([1.0, 0.0, 0.0], dtype="f4"), + joint_angles=np.zeros(7, dtype="f4"), + is_moving=True, + )) + + +if __name__ == "__main__": + cortex.run(StateBroadcaster().run()) +``` + +## Subscribe + +```python title="subscriber.py" +import cortex +from cortex import Node +from cortex.messages.base import MessageHeader +from messages import RobotState # same import, same fingerprint + + +async def on_state(msg: RobotState, header: MessageHeader): + if header.sequence % 100 == 0: + print(f"t={msg.timestamp:.3f} pos={msg.position}") + + +class Monitor(Node): + def __init__(self): + super().__init__("monitor") + self.create_subscriber("/robot/state", RobotState, callback=on_state) + + +if __name__ == "__main__": + cortex.run(Monitor().run()) +``` + +## How the dataclass becomes a wire message + +```mermaid +flowchart LR + DC[dataclass fields] --> FP[fingerprint] + DC --> ORD[declaration order] + ORD --> Enc[serialize_message_frames
values in order] + Enc --> Meta[metadata frame] + Enc --> Bufs[array frames] + FP --> Hdr[24-byte header] + Hdr --> Wire[(multipart send)] + Meta --> Wire + Bufs --> Wire +``` + +See [Concepts → Message wire format](../concepts/message-wire-format.md) for +the full picture. + +## Supported field types + +| Field type | Notes | +| ------------------------------- | ------------------------------------------------------- | +| `int` / `float` / `bool` / `str`| Plain msgpack primitives | +| `bytes` | msgpack bin | +| `list[...]` / `tuple[...]` | Walked recursively | +| `dict[str, Any]` | Walked recursively; arrays inside are still OOB | +| `np.ndarray` | OOB frame; zero-copy decode | +| `torch.Tensor` | OOB frame; CPU-transported, device restored on decode | +| Optional nested `Message` | Not first-class today — flatten instead | + +## Evolution: what breaks the fingerprint + +Changing any of these **changes the fingerprint** and makes old and new +publishers/subscribers incompatible: + +- Renaming the class, its module, or any field +- Adding a field (even with a default) +- Removing a field +- Changing a field's annotation text + +Safe to change without breaking: + +- Reordering methods, adding methods +- Editing docstrings or defaults +- Changing unrelated classes in the same module + +See [critique § 22](../critique.md) for the roadmap on first-class schema +evolution. + +## See also + +- [Concepts → Fingerprinting](../concepts/fingerprinting.md) +- [Components → Messages](../components/messages.md) +- [Tutorials → Multi-node system](multi-node-system.md) for custom messages used across multiple nodes diff --git a/docs/tutorials/multi-node-system.md b/docs/tutorials/multi-node-system.md new file mode 100644 index 0000000..6f6dbe8 --- /dev/null +++ b/docs/tutorials/multi-node-system.md @@ -0,0 +1,179 @@ +# Multi-node system + +A walk-through of [`examples/multi_node_system.py`](https://github.com/sudoRicheek/cortex/blob/main/examples/multi_node_system.py) — +a sensor → processor → monitor pipeline with custom messages, multiple +publishers and subscribers, and periodic status reporting. + +## Topology + +```mermaid +flowchart LR + subgraph Sensors[Sensor nodes] + S1["sensor_lidar
10 Hz"] + S2["sensor_camera
10 Hz"] + end + Proc[processor] + Mon[monitor] + + S1 -- "/sensor/lidar/raw" --> Proc + S2 -- "/sensor/camera/raw" --> Proc + Proc -- "/processed/data" --> Mon + Mon -- "/system/status
1 Hz" --> World((world)) +``` + +Four nodes run in a single Python process, each on the same asyncio event +loop via `asyncio.gather`. Cortex's IPC transport does not care that they +share a process — the data still rides through real ZMQ sockets. + +## Message schema + +Three custom message types share a module so every node gets the same +fingerprints: + +```python +@dataclass +class SensorReading(Message): + sensor_id: str + timestamp: float + values: np.ndarray + temperature: float + +@dataclass +class ProcessedData(Message): + source_sensor: str + timestamp: float + filtered_values: np.ndarray + statistics: dict # {mean, std, min, max} + +@dataclass +class SystemStatus(Message): + timestamp: float + num_sensors: int + processing_rate_hz: float + total_messages: int +``` + +## Sensor node + +```python +class SensorNode(Node): + def __init__(self, sensor_id: str, publish_rate: float = 10.0): + super().__init__(f"sensor_{sensor_id}") + self.reading_pub = self.create_publisher( + f"/sensor/{sensor_id}/raw", SensorReading + ) + self.create_timer(1.0 / publish_rate, self._publish_reading) + + async def _publish_reading(self): + t = time.time() + values = np.sin(np.linspace(0, 2*np.pi, 100) + t) + 0.1*np.random.randn(100) + self.reading_pub.publish(SensorReading( + sensor_id=self.sensor_id, + timestamp=t, + values=values.astype("f4"), + temperature=25.0 + 0.5*np.random.randn(), + )) +``` + +## Processor node + +Subscribes to every sensor and republishes filtered data: + +```python +class ProcessorNode(Node): + def __init__(self, sensor_ids: list[str]): + super().__init__("processor") + for sid in sensor_ids: + self.create_subscriber( + f"/sensor/{sid}/raw", SensorReading, callback=self._on_reading + ) + self.processed_pub = self.create_publisher("/processed/data", ProcessedData) + + async def _on_reading(self, msg: SensorReading, header: MessageHeader): + filtered = np.convolve(msg.values, np.ones(5) / 5, mode="same") + self.processed_pub.publish(ProcessedData( + source_sensor=msg.sensor_id, + timestamp=msg.timestamp, + filtered_values=filtered.astype("f4"), + statistics={ + "mean": float(filtered.mean()), + "std": float(filtered.std()), + "min": float(filtered.min()), + "max": float(filtered.max()), + }, + )) +``` + +## Monitor node + +Tracks throughput and publishes a periodic status message: + +```python +class MonitorNode(Node): + def __init__(self): + super().__init__("monitor") + self.create_subscriber("/processed/data", ProcessedData, callback=self._on_processed) + self.status_pub = self.create_publisher("/system/status", SystemStatus) + self.create_timer(1.0, self._publish_status) +``` + +## Running the whole graph + +```python +async def main(): + sensor_nodes = [SensorNode(sid, publish_rate=10.0) for sid in ["lidar", "camera"]] + processor_node = ProcessorNode(["lidar", "camera"]) + monitor_node = MonitorNode() + all_nodes = [*sensor_nodes, processor_node, monitor_node] + + await asyncio.sleep(1.0) # let topics register and subscribers connect + + try: + await asyncio.gather(*[n.run() for n in all_nodes]) + finally: + for n in all_nodes: + await n.close() +``` + +## Timeline + +```mermaid +sequenceDiagram + participant L as lidar + participant C as camera + participant P as processor + participant M as monitor + + par at 10 Hz + L->>P: SensorReading + and + C->>P: SensorReading + end + P->>M: ProcessedData (per reading) + Note over M: counts per second + M->>M: publish SystemStatus (1 Hz) +``` + +## Run it yourself + +```bash +# Terminal 1 +cortex-discovery + +# Terminal 2 +python examples/multi_node_system.py +``` + +Expected output: + +``` +[processor] Sensor lidar: mean=0.012, std=0.708 +[processor] Sensor camera: mean=-0.034, std=0.711 +[monitor] System status: 192 messages, 19.2 Hz processing rate +``` + +## See also + +- [Tutorials → Custom messages](custom-messages.md) +- [Components → Publisher & Subscriber](../components/publisher-subscriber.md) +- [Components → Node & Executors](../components/node-and-executors.md) diff --git a/docs/tutorials/numpy-and-images.md b/docs/tutorials/numpy-and-images.md new file mode 100644 index 0000000..04e7e15 --- /dev/null +++ b/docs/tutorials/numpy-and-images.md @@ -0,0 +1,97 @@ +# NumPy arrays & images + +Cortex treats NumPy arrays as first-class payloads. Array bytes travel as +separate ZMQ frames and are reconstructed with `np.frombuffer` on the +receiver — no intermediate `bytes` object, no extra copy. + +## Pattern: publisher that emits synthetic frames + +```python title="camera.py" +import numpy as np +import cortex +from cortex import Node, ArrayMessage + + +class Camera(Node): + def __init__(self): + super().__init__("camera") + self.pub = self.create_publisher("/cam/frame", ArrayMessage) + self.create_timer(1 / 30, self.tick) # 30 fps + self._i = 0 + + async def tick(self): + # Synthetic 640x480 RGB frame + frame = (np.random.rand(480, 640, 3) * 255).astype("uint8") + self.pub.publish(ArrayMessage(data=frame, name=f"f{self._i}", frame_id="camera")) + self._i += 1 + + +cortex.run(Camera().run()) +``` + +## Pattern: subscriber that processes frames + +```python title="viewer.py" +import numpy as np +import cortex +from cortex import Node, ArrayMessage +from cortex.messages.base import MessageHeader + + +async def on_frame(msg: ArrayMessage, header: MessageHeader): + # msg.data aliases the ZMQ frame buffer — copy before mutating + frame = msg.data.copy() + frame[..., 0] = 0 # zero out red channel + print(f"[{header.sequence}] {msg.name} mean={frame.mean():.1f}") + + +class Viewer(Node): + def __init__(self): + super().__init__("viewer") + self.create_subscriber("/cam/frame", ArrayMessage, callback=on_frame) + + +cortex.run(Viewer().run()) +``` + +## Aliasing rule of thumb + +```mermaid +flowchart LR + A[recv multipart
copy=False] --> B[np.frombuffer view] + B --> C{Do you...} + C -->|only read inside callback| OK[Use as-is: fastest] + C -->|mutate| CP[arr = arr.copy] + C -->|keep past callback| CP + C -->|pass to another thread| CP + CP --> Safe[safe, owned copy] +``` + +## `ImageMessage` specifics + +[`ImageMessage`][cortex.messages.standard.ImageMessage] carries an `encoding` +string plus optional `width` / `height` (auto-filled from the array shape): + +```python +from cortex.messages.standard import ImageMessage + +msg = ImageMessage(data=frame, encoding="rgb8") # width/height filled on __post_init__ +pub.publish(msg) +``` + +Encodings are free-form strings — Cortex does no validation or conversion. +Downstream code decides what `rgb8` / `bgr8` / `mono8` mean. + +## Zero-copy footprint + +A 1080p RGB frame is ~6 MB. On the benchmark suite: + +- Allocation on encode: **zero** (array is passed by view). +- Allocation on decode: **zero** (array is a view into the ZMQ frame). +- Throughput: ~1400 fps on a modern workstation. + +## See also + +- [Concepts → Message wire format](../concepts/message-wire-format.md) +- [Components → Serialization](../components/serialization.md) +- [Guides → Performance tuning](../guides/performance-tuning.md) diff --git a/docs/tutorials/pytorch-tensors.md b/docs/tutorials/pytorch-tensors.md new file mode 100644 index 0000000..0e06503 --- /dev/null +++ b/docs/tutorials/pytorch-tensors.md @@ -0,0 +1,110 @@ +# PyTorch tensors + +[`TensorMessage`][cortex.messages.standard.TensorMessage] lets you pipe +tensors between processes with the same zero-copy multipart transport used +for NumPy arrays. Device and `requires_grad` metadata are preserved; the +bytes travel via the CPU side of the tensor. + +## Publish + +```python title="inference_producer.py" +import torch +import cortex +from cortex import Node, TensorMessage + + +class Inference(Node): + def __init__(self): + super().__init__("inference") + self.pub = self.create_publisher("/model/features", TensorMessage) + self.create_timer(1 / 30, self.tick) + + async def tick(self): + # Fake feature tensor; could be output of a real model + feats = torch.randn(4, 256, 7, 7, device="cuda" if torch.cuda.is_available() else "cpu") + self.pub.publish(TensorMessage(data=feats, name="layer4_feats")) + + +cortex.run(Inference().run()) +``` + +## Subscribe + +```python title="downstream_consumer.py" +import cortex +from cortex import Node, TensorMessage +from cortex.messages.base import MessageHeader + + +async def on_features(msg: TensorMessage, header: MessageHeader): + t = msg.data + print(f"{msg.name}: shape={tuple(t.shape)} device={t.device} grad={t.requires_grad}") + + +class Consumer(Node): + def __init__(self): + super().__init__("consumer") + self.create_subscriber("/model/features", TensorMessage, callback=on_features) + + +cortex.run(Consumer().run()) +``` + +## What gets preserved + +```mermaid +flowchart LR + A[torch.Tensor
cuda:0, grad=True] --> B[encode: .detach.cpu.numpy
contiguous] + B --> C[OOB frame + metadata
device_str, requires_grad, dtype, shape] + C -. IPC .-> D[decode: np.frombuffer
torch.from_numpy] + D --> E{cuda available?} + E -- yes --> F[move to device_str] + E -- no --> G[stay on CPU] + F --> H[requires_grad_ True if flagged] + G --> H +``` + +| Attribute | Transported | +| -------------------- | ------------------------ | +| `dtype` | ✓ exact | +| `shape` | ✓ | +| `device` | ✓ string; restored on decode if available | +| `requires_grad` | ✓ | +| `grad` (the actual gradient) | ✗ not sent | +| autograd graph | ✗ not sent (`detach()` is implicit) | + +## Multi-tensor payloads + +When you need several tensors together — e.g. a model's inputs and outputs +— use [`MultiTensorMessage`][cortex.messages.standard.MultiTensorMessage]: + +```python +from cortex.messages.standard import MultiTensorMessage + +msg = MultiTensorMessage(tensors={ + "image": image_tensor, + "features": feat_tensor, + "logits": logit_tensor, +}) +pub.publish(msg) +``` + +Each tensor gets its own OOB frame; no bytes are copied into a container. + +## Caveats + +!!! warning "CPU detour is mandatory" + Even for two processes on the same GPU, tensors are DMA'd to CPU on send + and back to GPU on receive. That is a copy on each side. Cortex does not + currently support CUDA IPC — for tight in-process handoffs, prefer a + torch.multiprocessing queue or shared CUDA memory. + +!!! note "Install with the `torch` extra" + `TensorMessage` raises on construction if PyTorch is not installed. Use + `pip install -e ".[torch]"`. + +## See also + +- [Concepts → Message wire format](../concepts/message-wire-format.md) +- [Components → Serialization](../components/serialization.md) +- [Tutorials → NumPy arrays & images](numpy-and-images.md) From d87119814051eb7cbdb7918c74e6208a8e2c630b Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 18 Apr 2026 09:21:57 -0400 Subject: [PATCH 27/28] make docs and push to githhub pages --- .github/workflows/docs.yml | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ee67f19 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,64 @@ +name: Docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install package with docs extra + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + + - name: Build docs (strict) + run: zensical build --strict -f docs/mkdocs.yml + + - name: Upload built site + uses: actions/upload-artifact@v4 + with: + name: site + path: docs/site + retention-days: 7 + + deploy: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install package with docs extra + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + + - name: Deploy to GitHub Pages + run: zensical gh-deploy --force --clean -f docs/mkdocs.yml From 9b093c670f60a6e127eb8777a4746cf6157a25de Mon Sep 17 00:00:00 2001 From: Richeek Das Date: Sat, 18 Apr 2026 09:30:36 -0400 Subject: [PATCH 28/28] shorten readme --- README.md | 362 ++++++------------------------------------------------ 1 file changed, 40 insertions(+), 322 deletions(-) diff --git a/README.md b/README.md index a6bb394..97b2cf6 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,71 @@ # Cortex -A lightweight framework for inter-process communication using ZeroMQ. +Lightweight Python pub/sub over ZeroMQ, for robotics and beyond. -## Overview +**[Documentation](https://sudoRicheek.github.io/cortex/)** · [Quickstart](https://sudoRicheek.github.io/cortex/getting-started/quickstart/) · [API Reference](https://sudoRicheek.github.io/cortex/reference/) -Cortex provides a simple yet powerful way to build distributed systems in Python. It features: +## Overview -- **Publisher/Subscriber pattern** for decoupled communication -- **Discovery service** for automatic topic resolution -- **IPC transport** using ZeroMQ for low-latency local communication -- **64-bit fingerprint hashing** for fast message type identification -- **Native support** for NumPy arrays, PyTorch tensors, and Python dictionaries -- **Simple API** with Node, Publisher, Subscriber, and Executor abstractions +Cortex is a pub/sub communication layer built on ZeroMQ IPC. Nodes publish typed messages on named topics; subscribers receive them via async callbacks. A small discovery daemon handles endpoint resolution so publishers and subscribers find each other automatically. -## Architecture +- **Typed messages** with 64-bit fingerprint verification — no silent type mismatches +- **Zero-copy frames** for NumPy arrays and PyTorch tensors over IPC +- **uvloop-backed async** for low tail latency on Linux/macOS +- **Simple API**: `Node`, `Publisher`, `Subscriber`, rate-based `Executor` ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Discovery Daemon │ -│ ipc:///tmp/cortex_discovery │ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Topic Registry │ │ -│ │ /camera/image -> ipc:///tmp/cortex/topics/camera_... │ │ -│ │ /robot/state -> ipc:///tmp/cortex/topics/robot_... │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ▲ ▲ - │ REQ/REP │ REQ/REP - │ (register, lookup) │ (lookup) - │ │ -┌────────┴────────┐ ┌─────────┴────────┐ -│ Publisher Node │ │ Subscriber Node │ -│ │ │ │ -│ ┌───────────┐ │ PUB/SUB (IPC) │ ┌───────────┐ │ -│ │ Publisher ├──┼─────────────────────┼──► Subscriber│ │ -│ └───────────┘ │ │ └───────────┘ │ -└─────────────────┘ └──────────────────┘ +┌──────────────────────────────────────┐ +│ Discovery Daemon │ +│ ipc:///tmp/cortex_discovery │ +└──────┬───────────────────────┬───────┘ + │ REQ/REP (register) │ REQ/REP (lookup) +┌──────┴──────┐ ┌──────┴──────┐ +│ Publisher │─PUB/SUB─│ Subscriber │ +└─────────────┘ IPC └─────────────┘ ``` ## Installation ```bash -# Clone the repository git clone https://github.com/sudoRicheek/cortex.git cd cortex - -# Install in development mode -pip install -e ".[dev]" - -# With PyTorch support -pip install -e ".[all]" +pip install -e "." # core +pip install -e ".[torch]" # + PyTorch ``` ## Quick Start -### 1. Start the Discovery Daemon - -The discovery daemon must be running for publishers and subscribers to find each other: - ```bash -# Start the discovery daemon -cortex-discovery +cortex-discovery # terminal 1: start the discovery daemon ``` -### 2. Create a Publisher - ```python +# publisher.py import numpy as np from cortex import Node, ArrayMessage -# Create a node -node = Node(name="sensor_node") - -# Create a publisher -pub = node.create_publisher( - topic_name="/sensor/data", - message_type=ArrayMessage, -) - -# Publish messages -data = np.random.randn(100, 100).astype(np.float32) -pub.publish(ArrayMessage(data=data, name="frame_0")) +node = Node("sensor") +pub = node.create_publisher("/sensor/data", ArrayMessage) +pub.publish(ArrayMessage(data=np.random.randn(640, 480, 3).astype("f4"), name="frame")) ``` -### 3. Create a Subscriber - ```python +# subscriber.py from cortex import Node, ArrayMessage -from cortex.messages.base import MessageHeader - -def on_data_received(msg: ArrayMessage, header: MessageHeader): - print(f"Received: {msg.name}, shape={msg.data.shape}") - -# Create a node -node = Node(name="processor_node") -# Create a subscriber -sub = node.create_subscriber( - topic_name="/sensor/data", - message_type=ArrayMessage, - callback=on_data_received, -) +def on_msg(msg: ArrayMessage, header): + print(f"got {msg.name}: {msg.data.shape}") -# Spin to process callbacks +node = Node("proc") +node.create_subscriber("/sensor/data", ArrayMessage, callback=on_msg) node.spin() ``` -## Message Types +Custom message types, rate-based executors, multi-node systems — see the **[docs](https://sudoRicheek.github.io/cortex/)**. -### Built-in Messages +## Messages -Cortex provides several built-in message types: - -```python -from cortex.messages.standard import ( - StringMessage, # Simple strings - IntMessage, # Integers - FloatMessage, # Floating point numbers - DictMessage, # Nested dictionaries - ArrayMessage, # NumPy arrays - TensorMessage, # PyTorch tensors - ImageMessage, # Image data - PointCloudMessage,# 3D point clouds - PoseMessage, # 6DOF poses -) -``` - -### Custom Messages - -Define your own messages using Python dataclasses: +Define messages as plain dataclasses — registration, fingerprinting, and serialization are automatic: ```python from dataclasses import dataclass @@ -138,249 +74,31 @@ from cortex.messages.base import Message @dataclass class RobotState(Message): - timestamp: float - position: np.ndarray # [x, y, z] - velocity: np.ndarray # [vx, vy, vz] + position: np.ndarray # zero-copy over IPC joint_angles: np.ndarray is_moving: bool ``` -Custom messages are automatically: -- Registered with the type system -- Assigned a 64-bit fingerprint for fast identification -- Serialized/deserialized efficiently - -## Working with Different Data Types - -### NumPy Arrays - -```python -from cortex import ArrayMessage -import numpy as np - -# Create array message -arr = np.random.randn(480, 640, 3).astype(np.float32) -msg = ArrayMessage(data=arr, name="rgb_image", frame_id="camera") - -# Publish -pub.publish(msg) -``` - -### PyTorch Tensors - -```python -from cortex import TensorMessage -import torch - -# Create tensor message -tensor = torch.randn(4, 256, 7, 7) -msg = TensorMessage(data=tensor, name="features") - -# Tensors are automatically moved to CPU for serialization -# Device information is preserved -pub.publish(msg) -``` - -### Dictionaries (Nested Structures) - -```python -from cortex import DictMessage -import numpy as np - -# Complex nested data -state = { - "timestamp": 1234567890.0, - "pose": { - "position": {"x": 1.0, "y": 2.0, "z": 0.0}, - "orientation": {"x": 0, "y": 0, "z": 0, "w": 1}, - }, - "joint_positions": np.array([0.1, 0.2, 0.3]), - "status": {"is_moving": True, "battery": 85.5}, -} - -msg = DictMessage(data=state) -pub.publish(msg) -``` - -## Node API - -### Creating Publishers and Subscribers - -```python -from cortex import Node - -node = Node(name="my_node") - -# Publisher -pub = node.create_publisher("/topic", MessageType, queue_size=10) - -# Subscriber with callback -sub = node.create_subscriber( - "/topic", - MessageType, - callback=my_callback, - wait_for_topic=True, # Wait for publisher to appear - topic_timeout=30.0, # Timeout for waiting -) -``` - -### Using Timers - -```python -def periodic_task(): - print("Timer fired!") - -# Create a timer that fires every 100ms -node.create_timer(0.1, periodic_task) - -# Spin to process timers and callbacks -node.spin() -``` - -### Using the Executor - -For multi-node systems: - -```python -from cortex.core.executor import SingleThreadedExecutor, MultiThreadedExecutor - -# Single-threaded execution -executor = SingleThreadedExecutor() -executor.add_node(node1) -executor.add_node(node2) -executor.spin() - -# Multi-threaded (each node in its own thread) -executor = MultiThreadedExecutor(num_threads=4) -executor.add_node(node1) -executor.add_node(node2) -executor.spin() -``` - -## Discovery Service - -### Using the Discovery Client Directly - -```python -from cortex.discovery import DiscoveryClient, TopicInfo - -client = DiscoveryClient() - -# List all topics -topics = client.list_topics() -for topic in topics: - print(f"{topic.name} -> {topic.address}") - -# Wait for a specific topic -topic_info = client.wait_for_topic("/camera/image", timeout=30.0) - -# Manual topic registration -info = TopicInfo( - name="/my/topic", - address="ipc:///tmp/my_socket", - message_type="MyMessage", - fingerprint=12345, - publisher_node="my_node" -) -client.register_topic(info) -``` - -## Message Fingerprinting - -Each message type has a unique 64-bit fingerprint computed from: -- The fully qualified class name -- Field names and types - -This allows fast message identification without parsing: - -```python -from cortex.messages.base import Message -from cortex.utils.hashing import compute_fingerprint - -@dataclass -class MyMessage(Message): - value: int - -# Get the fingerprint -fp = MyMessage.fingerprint() # e.g., 0x1234567890ABCDEF - -# Fingerprint is sent with every message for type verification -``` +Built-ins cover the common cases: `StringMessage`, `ArrayMessage`, `ImageMessage`, `PointCloudMessage`, `PoseMessage`, `TensorMessage`, and more. See the [Messages reference](https://sudoRicheek.github.io/cortex/components/messages/). ## Examples -See the `examples/` directory for complete examples: - -- `publisher_numpy.py` / `subscriber_numpy.py` - NumPy array transfer -- `publisher_dict.py` / `subscriber_dict.py` - Dictionary messages -- `publisher_tensor.py` / `subscriber_tensor.py` - PyTorch tensor transfer -- `multi_node_system.py` - Complete multi-node system example - -Run examples: +See the `examples/` directory for complete examples. One example: ```bash -# Terminal 1 -python -m cortex.discovery.daemon - -# Terminal 2 -python examples/publisher_numpy.py - -# Terminal 3 -python examples/subscriber_numpy.py +python -m cortex.discovery.daemon # Terminal 1 +python examples/publisher_numpy.py # Terminal 2 +python examples/subscriber_numpy.py # Terminal 3 ``` +Full walkthroughs in the [Tutorials](https://sudoRicheek.github.io/cortex/tutorials/custom-messages/). + ## Testing ```bash -# Run all tests pytest - -# Run with coverage -pytest --cov=cortex - -# Run specific test file -pytest tests/test_messages.py -v -``` - -## Project Structure - ``` -cortex/ -├── src/cortex/ -│ ├── __init__.py # Package exports -│ ├── core/ -│ │ ├── node.py # Node abstraction -│ │ ├── publisher.py # Publisher implementation -│ │ ├── subscriber.py # Subscriber implementation -│ │ └── executor.py # Executor for multi-node -│ ├── discovery/ -│ │ ├── daemon.py # Discovery daemon -│ │ ├── client.py # Discovery client -│ │ └── protocol.py # Protocol definitions -│ ├── messages/ -│ │ ├── base.py # Base message class -│ │ └── standard.py # Standard message types -│ └── utils/ -│ ├── hashing.py # Fingerprint computation -│ └── serialization.py # Data serialization -├── tests/ # Unit tests -├── examples/ # Example applications -└── pyproject.toml # Package configuration -``` - -## Performance - -Cortex is designed for lightweight, high-frequency communication: - -- **IPC transport**: ZeroMQ IPC sockets for minimal latency -- **Zero-copy where possible**: NumPy arrays use efficient byte views -- **Fingerprint-based dispatch**: O(1) message type lookup -- **Minimal overhead**: Simple binary protocol without XML/JSON parsing - -Typical latencies on a modern system: -- Small messages (< 1KB): < 100 µs -- Large arrays (1 MB): < 5 ms ## License -Apache License 2.0 - see the [LICENSE](LICENSE) file for details. +Apache 2.0 — see [LICENSE](LICENSE).