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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..110a253 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Lint + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +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/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..eb50440 --- /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", "3.13", "3.14"] + + 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/README.md b/README.md new file mode 100644 index 0000000..97b2cf6 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Cortex + +Lightweight Python pub/sub over ZeroMQ, for robotics and beyond. + +**[Documentation](https://sudoRicheek.github.io/cortex/)** · [Quickstart](https://sudoRicheek.github.io/cortex/getting-started/quickstart/) · [API Reference](https://sudoRicheek.github.io/cortex/reference/) + +## Overview + +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. + +- **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 │ +└──────┬───────────────────────┬───────┘ + │ REQ/REP (register) │ REQ/REP (lookup) +┌──────┴──────┐ ┌──────┴──────┐ +│ Publisher │─PUB/SUB─│ Subscriber │ +└─────────────┘ IPC └─────────────┘ +``` + +## Installation + +```bash +git clone https://github.com/sudoRicheek/cortex.git +cd cortex +pip install -e "." # core +pip install -e ".[torch]" # + PyTorch +``` + +## Quick Start + +```bash +cortex-discovery # terminal 1: start the discovery daemon +``` + +```python +# publisher.py +import numpy as np +from cortex import Node, ArrayMessage + +node = Node("sensor") +pub = node.create_publisher("/sensor/data", ArrayMessage) +pub.publish(ArrayMessage(data=np.random.randn(640, 480, 3).astype("f4"), name="frame")) +``` + +```python +# subscriber.py +from cortex import Node, ArrayMessage + +def on_msg(msg: ArrayMessage, header): + print(f"got {msg.name}: {msg.data.shape}") + +node = Node("proc") +node.create_subscriber("/sensor/data", ArrayMessage, callback=on_msg) +node.spin() +``` + +Custom message types, rate-based executors, multi-node systems — see the **[docs](https://sudoRicheek.github.io/cortex/)**. + +## Messages + +Define messages as plain dataclasses — registration, fingerprinting, and serialization are automatic: + +```python +from dataclasses import dataclass +import numpy as np +from cortex.messages.base import Message + +@dataclass +class RobotState(Message): + position: np.ndarray # zero-copy over IPC + joint_angles: np.ndarray + is_moving: bool +``` + +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. One example: + +```bash +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 +pytest +``` + +## License + +Apache 2.0 — see [LICENSE](LICENSE). 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..5487cd9 --- /dev/null +++ b/benchmarks/bench_all.py @@ -0,0 +1,371 @@ +#!/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 +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: + """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 wire serialization overhead using multipart transport frames.""" + + 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: + 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): + sender.send_multipart([topic, *message.to_frames()], copy=False) + warmup_frames = receiver.recv_multipart(copy=False) + ArrayMessage.from_frames(warmup_frames[1:]) + + # Benchmark A->B wire transfer and B-side decode + iterations = 100 + wire_total = 0.0 + decode_total = 0.0 + frames = [] + + for _ in range(iterations): + 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() + + 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) + + # 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, + # 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}: to_wire={serialize_time:.3f}ms, from_wire={deserialize_time:.3f}ms" + ) + + sender.close() + receiver.close() + + 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}" + ) + + # Wire serialization overhead + print("\n📊 WIRE SERIALIZATION OVERHEAD (MULTIPART)") + print("-" * 60) + 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['wire_size_bytes']:>12,} " + f"{data['serialize_ms']:>10.3f}ms " + f"{data['deserialize_ms']:>10.3f}ms " + f"{data['roundtrip_ms']:>10.3f}ms" + ) + + 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..c2be413 --- /dev/null +++ b/benchmarks/bench_latency.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Latency benchmark for Cortex. + +Measures round-trip latency between publisher and subscriber. +""" + +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")) + +import cortex +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=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() + + # 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, + } + ) + + cortex.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..e4a64cd --- /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. +""" + +import asyncio +import builtins +import contextlib +import logging +import threading +import time +from dataclasses import dataclass +from typing import Any + +import numpy as np + +import cortex +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 + + cortex.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) + 16777216, # 16 MB - very large (like high-res 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/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/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/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 0000000..1cf13b9 Binary files /dev/null and b/docs/site/assets/images/favicon.png differ 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 0000000..f2135e7 Binary files /dev/null and b/docs/site/objects.inv differ diff --git a/docs/site/search.json b/docs/site/search.json new file mode 100644 index 0000000..ee939f5 --- /dev/null +++ b/docs/site/search.json @@ -0,0 +1 @@ +{"config":{"separator":"[\\s\\-_,:!=\\[\\]()\\\\\"`/]+|\\.(?!\\d)"},"items":[{"location":"","level":1,"title":"Cortex","text":"

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.

","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) 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..1160d09 --- /dev/null +++ b/examples/multi_node_system.py @@ -0,0 +1,240 @@ +#!/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 +""" + +import asyncio +import contextlib +import time +from dataclasses import dataclass + +import numpy as np + +import cortex +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): + cortex.run(main()) diff --git a/examples/publisher_dict.py b/examples/publisher_dict.py new file mode 100644 index 0000000..0552b2f --- /dev/null +++ b/examples/publisher_dict.py @@ -0,0 +1,112 @@ +#!/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 +""" + +import asyncio +import contextlib +import time + +import numpy as np + +import cortex +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): + cortex.run(main()) diff --git a/examples/publisher_numpy.py b/examples/publisher_numpy.py new file mode 100644 index 0000000..bd14a25 --- /dev/null +++ b/examples/publisher_numpy.py @@ -0,0 +1,86 @@ +#!/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 numpy as np + +import cortex +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__": + cortex.run(main()) diff --git a/examples/publisher_tensor.py b/examples/publisher_tensor.py new file mode 100644 index 0000000..e17362e --- /dev/null +++ b/examples/publisher_tensor.py @@ -0,0 +1,86 @@ +#!/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 +""" + +try: + import torch +except ImportError: + print("This example requires PyTorch. Install with: pip install torch") + exit(1) + +import cortex +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__": + cortex.run(main()) diff --git a/examples/subscriber_dict.py b/examples/subscriber_dict.py new file mode 100644 index 0000000..e977310 --- /dev/null +++ b/examples/subscriber_dict.py @@ -0,0 +1,93 @@ +#!/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 +""" + +import asyncio +import contextlib + +import cortex +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") + # Create a subscriber - connection happens asynchronously in run() + print("Waiting for publisher on /robot/state...") + self.create_subscriber( + topic_name="/robot/state", + message_type=DictMessage, + callback=self._on_state_received, + wait_for_topic=True, + topic_timeout=30.0, + ) + + print("Subscriber created, will connect when run() is called...") + 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...") + + node = DictSubscriberNode() + + try: + await node.run() + except asyncio.CancelledError: + pass + finally: + await node.close() + print("Shutting down...") + + +if __name__ == "__main__": + with contextlib.suppress(KeyboardInterrupt): + cortex.run(main()) diff --git a/examples/subscriber_numpy.py b/examples/subscriber_numpy.py new file mode 100644 index 0000000..369d19c --- /dev/null +++ b/examples/subscriber_numpy.py @@ -0,0 +1,73 @@ +#!/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 cortex +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 - connection happens asynchronously in run() + 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, + ) + + print("Subscriber created, will connect when run() is called...") + print("Press Ctrl+C to stop") + print() + + +async def main(): + """Run the subscriber example.""" + print("Starting NumPy array subscriber...") + + node = ArraySubscriberNode() + + try: + await node.run() + except KeyboardInterrupt: + print("\nShutting down...") + finally: + await node.close() + + +if __name__ == "__main__": + cortex.run(main()) diff --git a/examples/subscriber_tensor.py b/examples/subscriber_tensor.py new file mode 100644 index 0000000..82a806b --- /dev/null +++ b/examples/subscriber_tensor.py @@ -0,0 +1,83 @@ +#!/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 +""" + +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 + + +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 - connection happens asynchronously in run() + 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, + ) + + print("Subscriber created, will connect when run() is called...") + 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__}") + + node = TensorSubscriberNode() + + try: + await node.run() + except KeyboardInterrupt: + print("\nShutting down...") + finally: + await node.close() + + +if __name__ == "__main__": + cortex.run(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2cb9345 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[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 = "Apache-2.0"} +requires-python = ">=3.10" +authors = [ + {name = "Richeek Das", email = "richeek@seas.upenn.edu"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "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] +torch = [ + "torch>=2.6.0", +] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "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,docs]", +] + +[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 +target-version = "py310" + +[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..f680d7c --- /dev/null +++ b/src/cortex/__init__.py @@ -0,0 +1,43 @@ +""" +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 (with uvloop on Unix) +""" + +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 +from cortex.messages.base import Message, MessageType +from cortex.messages.standard import ( + ArrayMessage, + DictMessage, + FloatMessage, + IntMessage, + StringMessage, + TensorMessage, +) +from cortex.utils.loop import run + +__version__ = "0.1.0" +__all__ = [ + "Node", + "Publisher", + "Subscriber", + "AsyncExecutor", + "RateExecutor", + "Message", + "MessageType", + "ArrayMessage", + "TensorMessage", + "DictMessage", + "StringMessage", + "FloatMessage", + "IntMessage", + "run", +] diff --git a/src/cortex/core/__init__.py b/src/cortex/core/__init__.py new file mode 100644 index 0000000..3661048 --- /dev/null +++ b/src/cortex/core/__init__.py @@ -0,0 +1,22 @@ +"""Core module for Cortex framework.""" + +from cortex.core.executor import ( + AsyncExecutor, + BaseExecutor, + RateExecutor, +) +from cortex.core.node import Node +from cortex.core.publisher import Publisher +from cortex.core.subscriber import Subscriber +from cortex.core.types import AsyncCallback, MessageCallback + +__all__ = [ + "Node", + "Publisher", + "Subscriber", + "AsyncCallback", + "MessageCallback", + "BaseExecutor", + "AsyncExecutor", + "RateExecutor", +] diff --git a/src/cortex/core/executor.py b/src/cortex/core/executor.py new file mode 100644 index 0000000..668d272 --- /dev/null +++ b/src/cortex/core/executor.py @@ -0,0 +1,151 @@ +""" +Executor for managing async functions at constant rates. + +Provides utilities for executing async callbacks with precise timing, +faithful to Python's cooperative multitasking model. +""" + +import asyncio +import logging +import time +from abc import ABC, abstractmethod + +from cortex.core.types import AsyncCallback + +logger = logging.getLogger("cortex.executor") + + +class BaseExecutor(ABC): + """ + Abstract base class for async executors. + + Provides common interface for starting, stopping, and running + async callback functions. + """ + + def __init__(self, func: AsyncCallback): + """ + Initialize the executor. + + Args: + func: Async function to execute + """ + self.func = func + self._running = False + + @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() + + @abstractmethod + async def _run_impl(self, *args, **kwargs) -> None: + """Implementation of the run loop. Subclasses must override.""" + ... + + +class AsyncExecutor(BaseExecutor): + """Runs an async callable in a tight loop, yielding to the event loop. + + 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: + """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 AsyncExecutor: {e}") + await asyncio.sleep(0) + + +class RateExecutor(BaseExecutor): + """Runs an async callable at a target rate in Hz. + + 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): + """ + 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. + + 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 RateExecutor: {e}") + await asyncio.sleep(0) + + await asyncio.sleep(max(0, next_exec_time - time.perf_counter())) diff --git a/src/cortex/core/node.py b/src/cortex/core/node.py new file mode 100644 index 0000000..3bef724 --- /dev/null +++ b/src/cortex/core/node.py @@ -0,0 +1,293 @@ +""" +Node abstraction for Cortex. + +Provides an async interface for managing publishers and subscribers. +Uses asyncio for cooperative multitasking - ideal for Python < 3.14. +""" + +import asyncio +import logging + +import zmq +import zmq.asyncio + +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") + + +class Node: + """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. + + :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) + + async def publish_image(self): + self.pub.publish(ImageMessage(data=capture_image())) + + async def main(): + async with CameraNode() as node: + await node.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, RateExecutor]] = [] + + # Subscribers with callbacks (need to run receive loops) + self._active_subscribers: list[Subscriber] = [] + + # 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 + """ + 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 + """ + 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 to active list if it has a callback + if callback is not None: + self._active_subscribers.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 + """ + 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)") + + 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: + 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") + + 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 sub in self._active_subscribers: + sub.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 sub in self._active_subscribers: + sub.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._active_subscribers.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..8a2123d --- /dev/null +++ b/src/cortex/core/publisher.py @@ -0,0 +1,258 @@ +""" +Publisher implementation for Cortex. + +Provides a ZeroMQ-based publisher that registers with the discovery daemon +and publishes messages on IPC sockets using asyncio. +""" + +import contextlib +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, node_name: str) -> str: + """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. + + 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("_") + + # Ensure the directory exists + ipc_dir = "/tmp/cortex/topics" + os.makedirs(ipc_dir, exist_ok=True) + + return f"ipc://{ipc_dir}/{safe_name}.sock" + + +class Publisher: + """Sends typed messages on a topic over a ZMQ PUB socket. + + 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. + + 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__( + 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 | zmq.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: Shared ZMQ async context or sync context (optional) + """ + 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, 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 + 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._socket: zmq.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 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) + + # Set high-water mark (queue size) + self._socket.setsockopt(zmq.SNDHWM, self.queue_size) + + # 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) + + 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: + """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: 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 ZMQ accepted the message; ``False`` if the queue was + full (``zmq.Again``) or another send error was logged. + + Raises: + TypeError: If ``type(message)`` does not match :attr:`message_type`. + """ + if not isinstance(message, self.message_type): + raise TypeError( + f"Expected {self.message_type.__name__}, got {type(message).__name__}" + ) + + try: + # 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_bytes, *message.to_frames()], + 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 + + # Clean up IPC socket file + 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 new file mode 100644 index 0000000..a50fae6 --- /dev/null +++ b/src/cortex/core/subscriber.py @@ -0,0 +1,326 @@ +""" +Subscriber implementation for Cortex. + +Provides a ZeroMQ-based subscriber that queries the discovery daemon +and subscribes to topics using IPC sockets with asyncio. +""" + +import asyncio +import contextlib +import logging +import time +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 +from cortex.messages.base import Message, MessageHeader + +logger = logging.getLogger("cortex.subscriber") + + +class Subscriber: + """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`. + + 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`. + + 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__( + 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, + wait_for_topic: bool = True, + topic_timeout: float = 600.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 + 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 + """ + 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 + self._wait_for_topic = wait_for_topic + + # Connection info + self._topic_info: TopicInfo | None = None + self._connected = False + + # ZMQ setup - context provided by Node + self._context: zmq.asyncio.Context = context or zmq.asyncio.Context() + self._socket: zmq.asyncio.Socket | None = None + + # Discovery client + self._discovery_client: DiscoveryClient | None = DiscoveryClient( + discovery_address=self.discovery_address + ) + + # Statistics + self._receive_count = 0 + self._last_receive_time: float | None = None + + # Executor for receive loop + self._executor: AsyncExecutor | None = None + + # Try non-blocking connect (will succeed if topic already exists) + self._connect() + + def _connect(self) -> bool: + """ + Connect to the topic (non-blocking lookup only). + + Returns: + True if connected successfully + """ + try: + # 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 + + 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 = 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) + + 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) + + # Set high-water mark + self._socket.setsockopt(zmq.RCVHWM, self.queue_size) + + # 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) + + # 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, header, metadata, *buffers] + frames = await self._socket.recv_multipart(copy=False) + + if len(frames) < 2: + logger.warning(f"Unexpected frame count: {len(frames)}") + return None + + 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() + + return message, header + + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Failed to receive message: {e}") + return None + + async def _receive_and_callback(self) -> Any: + """Receive a message and invoke the callback.""" + result = await self.receive() + if result: + message, header = result + return await self._callback(message, header) + + def start(self) -> None: + """Start the subscriber receive loop.""" + if self._executor: + self._executor.start() + + def stop(self) -> None: + """Stop the subscriber receive loop.""" + 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 + + 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) + await self._executor.run() + + 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 and release resources.""" + logger.info(f"Closing subscriber for {self.topic_name}") + + # 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: + 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 + + self._connected = False diff --git a/src/cortex/core/types.py b/src/cortex/core/types.py new file mode 100644 index 0000000..6b0ca74 --- /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) -> Any +MessageCallback = Callable[[Message, MessageHeader], Coroutine[Any, Any, Any]] 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..e07fcdd --- /dev/null +++ b/src/cortex/discovery/client.py @@ -0,0 +1,286 @@ +""" +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. +""" + +import asyncio +import contextlib +import logging +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: + """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__( + self, + discovery_address: str = DEFAULT_DISCOVERY_ADDRESS, + timeout_ms: int = 5000, + retries: int = 1, + ): + """ + 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 = zmq.Context() + self._socket: zmq.Socket | None = None + + # 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._connect() + + def _send_request(self, request: DiscoveryRequest) -> DiscoveryResponse: + """Send a request and wait for response.""" + + last_error: Exception | None = None + + for attempt in range(self.retries): + try: + 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._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._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: + 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 + """ + 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 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 + + 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 (blocking). + + 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 + + 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. + + 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 close(self) -> None: + """Close the client connection.""" + if self._socket: + with contextlib.suppress(Exception): + self._socket.close() + self._socket = None + + with contextlib.suppress(Exception): + self._context.term() + + 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..c1a9aca --- /dev/null +++ b/src/cortex/discovery/daemon.py @@ -0,0 +1,323 @@ +""" +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.sock +""" + +import contextlib +import os + +import zmq + +from cortex.discovery.protocol import ( + DiscoveryCommand, + DiscoveryRequest, + DiscoveryResponse, + DiscoveryStatus, + TopicInfo, +) +from cortex.utils.logging import get_logger, set_log_level + +# Get logger for this module +logger = get_logger("cortex.discovery") + + +# Default discovery address +DEFAULT_DISCOVERY_ADDRESS = "ipc:///tmp/cortex/discovery.sock" + + +class DiscoveryDaemon: + """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. + + 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__( + self, + address: str = DEFAULT_DISCOVERY_ADDRESS, + ): + """ + Initialize the discovery daemon. + + Args: + address: ZMQ address to bind to (default: ipc:///tmp/cortex/discovery.sock) + """ + self.address = address + + # Topic registry: topic_name -> TopicInfo + self._topics: dict[str, TopicInfo] = {} + + # ZMQ context and socket + self._context: zmq.Context | None = None + self._socket: zmq.Socket | None = None + + # Control flag + self._running = False + + 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) + + #! 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 + + logger.info("=" * 50) + logger.info("DISCOVERY DAEMON STARTED") + logger.info(" Address: %s", self.address) + logger.info("=" * 50) + + try: + self._run_loop() + except KeyboardInterrupt: + logger.info("Received interrupt signal") + finally: + self._cleanup() + + def _run_loop(self) -> None: + """Main event loop.""" + while self._running: + try: + # Try to receive a request (blocks up to RCVTIMEO) + try: + 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: + # 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: + 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.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 + + 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 + + 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}" + ) + + 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", + ) + + 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] + + logger.info("-" * 50) + logger.info("UNREGISTER topic: %s", 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", + ) + + 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", + ) + + def _handle_list(self) -> DiscoveryResponse: + """Handle list all topics.""" + topics = list(self._topics.values()) + + logger.info("-" * 50) + logger.info("LIST topics: %d registered", len(topics)) + + return DiscoveryResponse(status=DiscoveryStatus.OK, topics=topics) + + def _handle_shutdown(self) -> DiscoveryResponse: + """Handle shutdown request.""" + self._running = False + logger.info("-" * 50) + logger.info("SHUTDOWN command received") + return DiscoveryResponse(status=DiscoveryStatus.OK, message="Shutting down") + + def _cleanup(self) -> None: + """Clean up resources.""" + if self._socket: + try: + 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("=" * 50) + 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.""" + 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( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="Logging level (default: INFO)", + ) + + args = parser.parse_args() + + # Set log level + set_log_level(logger, args.log_level) + + # Create and run daemon + daemon = DiscoveryDaemon(address=args.address) + + 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..a13eeda --- /dev/null +++ b/src/cortex/discovery/protocol.py @@ -0,0 +1,128 @@ +""" +Discovery protocol definitions for Cortex. + +Defines the request/response messages for the discovery service. +""" + +from dataclasses import dataclass +from enum import IntEnum + +import msgpack + + +class DiscoveryCommand(IntEnum): + """Commands for the discovery service.""" + + REGISTER_TOPIC = 1 + UNREGISTER_TOPIC = 2 + LOOKUP_TOPIC = 3 + LIST_TOPICS = 4 + 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: TopicInfo | None = None # For REGISTER/UNREGISTER + topic_name: str | None = 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: TopicInfo | None = None # For LOOKUP + topics: list[TopicInfo] | None = 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..91d8dfa --- /dev/null +++ b/src/cortex/messages/base.py @@ -0,0 +1,237 @@ +"""Base message classes for Cortex.""" + +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_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 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. + + Example: + >>> from cortex.messages.standard import ArrayMessage + >>> MessageType.get(ArrayMessage.fingerprint()) is ArrayMessage + True + """ + + _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: + """Fixed-size 24-byte header prepended to every Cortex message. + + Layout (big-endian): ``fingerprint u64 | timestamp_ns u64 | sequence u64``. + + 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 + timestamp_ns: int + sequence: int + + 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 + _field_names_cache: ClassVar[tuple[str, ...] | None] = None + + 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 + + @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. + + Format: + - 24 bytes: header (fingerprint, timestamp, sequence) + - remaining: serialized field data + """ + header_bytes = self._build_header().to_bytes() + data_bytes = serialize_message_values(self._field_values()) + return header_bytes + data_bytes + + def to_frames(self) -> list[object]: + """Serialize the message into transport frames. + + 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]: + """ + Deserialize a message from bytes. + + Returns: + Tuple of (message instance, header) + """ + header = MessageHeader.from_bytes(data) + values = deserialize_message_values(data[MessageHeader.size() :]) + return cls._build_instance(values), 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]: + """ + 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..1276b06 --- /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 + +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: 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 = "" + + +@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..7963b6f --- /dev/null +++ b/src/cortex/utils/__init__.py @@ -0,0 +1,15 @@ +"""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 + +__all__ = [ + "serialize", + "deserialize", + "compute_fingerprint", + "run", + "get_logger", + "set_log_level", +] 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/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/src/cortex/utils/loop.py b/src/cortex/utils/loop.py new file mode 100644 index 0000000..0170c32 --- /dev/null +++ b/src/cortex/utils/loop.py @@ -0,0 +1,43 @@ +""" +Event loop utilities for Cortex. + +Provides uvloop integration for improved async performance on Unix systems. +""" + +import asyncio +import importlib.util +import logging +import sys +from collections.abc import Coroutine +from typing import Any + +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: Coroutine[Any, Any, Any], *, debug: bool = False) -> Any: + """Run a coroutine, preferring ``uvloop`` when available. + + 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: The top-level coroutine to run to completion. + debug: Pass through to the event loop's ``debug`` flag. + + Returns: + Whatever ``coro`` returns. + """ + 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) diff --git a/src/cortex/utils/serialization.py b/src/cortex/utils/serialization.py new file mode 100644 index 0000000..cccc01a --- /dev/null +++ b/src/cortex/utils/serialization.py @@ -0,0 +1,425 @@ +"""Serialization utilities for Cortex messages.""" + +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 + + +_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. + + Format: + - 1 byte: number of dimensions + - 4 bytes per dim: shape + - variable: dtype string length (2 bytes) + dtype string + - remaining: raw array data + """ + 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 bytes(header) + contiguous.tobytes(order="C") + + +def deserialize_numpy( + data: bytes | memoryview, *, copy: bool = False +) -> tuple[np.ndarray, int]: + """ + Deserialize bytes to a NumPy array. + + Returns: + Tuple of (array, bytes_consumed) + """ + offset = 0 + view = _as_buffer_view(data) + + # Read ndim + ndim = struct.unpack(">B", view[offset : offset + 1])[0] + offset += 1 + + # Read shape + shape = struct.unpack(f">{ndim}I", view[offset : offset + 4 * ndim]) + offset += 4 * ndim + + # Read dtype + dtype_len = struct.unpack(">H", view[offset : offset + 2])[0] + offset += 2 + 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 = view[offset : offset + size] + offset += size + + arr = np.frombuffer(arr_data, dtype=dtype).reshape(shape) + if copy: + arr = arr.copy() + return arr, offset + + +def serialize_torch(tensor: Any) -> 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[Any, 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:], copy=True) + 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): + return struct.pack(">B", DataType.NUMPY) + serialize_numpy(value) + + if TORCH_AVAILABLE and isinstance(value, torch.Tensor): + return struct.pack(">B", DataType.TORCH) + serialize_torch(value) + + if isinstance(value, bytes): + return struct.pack(">BI", DataType.BYTES, len(value)) + value + + if isinstance(value, dict): + 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)): + 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 struct.pack(">BI", DataType.PRIMITIVE, len(packed)) + packed + + +def deserialize(data: bytes) -> tuple[Any, int]: + """ + Deserialize bytes to a value. + + Returns: + Tuple of (value, bytes_consumed) + """ + offset = 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(view[offset:]) + return arr, offset + arr_bytes + + if data_type == DataType.TORCH: + tensor, tensor_bytes = deserialize_torch(bytes(view[offset:])) + return tensor, offset + tensor_bytes + + if data_type == DataType.BYTES: + length = struct.unpack(">I", view[offset : offset + 4])[0] + offset += 4 + return bytes(view[offset : offset + length]), offset + length + + if data_type in (DataType.DICT, DataType.LIST, DataType.PRIMITIVE): + length = struct.unpack(">I", view[offset : offset + 4])[0] + offset += 4 + 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}") + + +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 + """ + return serialize(fields) + + +def deserialize_message_data(data: bytes) -> dict[str, Any]: + """Deserialize bytes to message fields.""" + fields, _ = deserialize(data) + return fields + + +def serialize_message_values(values: list[Any] | tuple[Any, ...]) -> bytes: + """Serialize ordered message field values.""" + return serialize(list(values)) + + +def deserialize_message_values(data: bytes | memoryview) -> list[Any]: + """Deserialize ordered message field values.""" + values, _ = deserialize(_as_buffer_view(data)) + return values + + +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] + + +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/__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..324c733 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,98 @@ +""" +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.sock" + + +@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() # start() calls stop() in finally block + + 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: + self._daemon.stop() + + # Wait for thread to finish (daemon cleans up its own context) + if self._thread: + self._thread.join(timeout=2.0) + self._thread = None + + self._daemon = 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..97ac9aa --- /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=5.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_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"}) 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..6afb7a6 --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,252 @@ +""" +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_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.""" + + @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..765c803 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,249 @@ +""" +Tests for Node class. +""" + +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..e97afed --- /dev/null +++ b/tests/test_pubsub.py @@ -0,0 +1,390 @@ +""" +Tests for publisher and subscriber. +""" + +import asyncio +import contextlib +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() + + @pytest.mark.asyncio + async def test_subscriber_waits_for_topic( + self, discovery_daemon, discovery_address + ): + """Subscriber should wait for topic to appear.""" + connected_event = asyncio.Event() + + async def subscriber_task(): + 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, + ) + # _async_connect is called in receive(), which waits for the topic + await sub.receive() # This triggers async wait + connected_event.set() + sub.close() + + # Start subscriber task (will wait for topic) + sub_task = asyncio.create_task(subscriber_task()) + + # Create publisher after delay + await asyncio.sleep(0.5) + pub = Publisher( + topic_name="/test/wait_topic", + message_type=SampleMessage, + node_name="pub_node", + discovery_address=discovery_address, + ) + + # 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: + """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..7fc88c1 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,271 @@ +""" +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}" + + 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.""" + + 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"]