Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,23 @@ TOPICFORGE_ROS2_BIN=ros2
# bag paths, no environment data — see the README "Telemetry" section.
TOPICFORGE_TELEMETRY=off

# DDS module backend: mock | cyclone | fast | rti | auto (default: mock).
# mock — deterministic fixtures, no SDK required (default)
# cyclone — Eclipse CycloneDDS, requires `pip install topicforge[dds-cyclone]`
# fast — eProsima Fast DDS, requires `pip install topicforge[dds-fast]`
# rti — RTI Connext (Pro tier, v0.4.0+ roadmap, BYO license)
# auto — pick best available: Fast > Cyclone > Mock
# DDS module backend: one of mock | cyclone | fast | rti | opensplice |
# coredx | intercom | opendds | dust | auto (default: mock).
# v0.4.0 Phase 1.5 widened the Literal — Pro tier vendors (rti, opensplice,
# coredx, intercom) are probed against the topicforge_pro package, not the
# upstream SDK ; the OSS core never imports a commercial binding directly.
#
# mock — deterministic fixtures, no SDK required (default)
# cyclone — Eclipse CycloneDDS, requires `pip install topicforge[dds-cyclone]`
# fast — eProsima Fast DDS, requires `pip install topicforge[dds-fast]`
# opendds — OpenDDS (OSS, awaiting upstream Python binding ; `topicforge[dds-opendds]`)
# dust — Dust DDS Rust (OSS, awaiting Python binding ; `topicforge[dds-dust]`)
# rti — RTI Connext (Pro tier, BYO license)
# opensplice — Vortex OpenSplice legacy (Pro tier, BYO license)
# coredx — Twin Oaks CoreDX (Pro tier, BYO license)
# intercom — TechSoft InterCOM (Pro tier, BYO license)
# auto — auto-detect chain: rti > opensplice > coredx > intercom (Pro
# tier, if installed) > opendds > fast > cyclone > dust > mock
# Both Cyclone and Fast are OMG DDS-RTPS conformant ; they observe every
# vendor on the bus — see docs/dds-interop-matrix.md for the canonical
# multi-vendor positioning and the OMG May 2025 interop reference.
Expand Down
2 changes: 1 addition & 1 deletion docs/DDS_QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Both real backends and the mock fixtures encode the canonical "subscriber doesn'

An LLM reading this output has enough information to suggest a concrete fix ("the writer is BEST_EFFORT but the reader requires RELIABLE — either relax the reader or upgrade the writer"). That is the diagnostic loop the DDS module is designed to support — and it works identically regardless of which backend produced the discovery samples, because the vendor-neutral pure analyzer at `src/topicforge/adapters/common/qos_analyzer.py` operates on canonical `QosProfile` Pydantic models.

The analyzer covers the four MVP policies — **Reliability**, **Durability**, **History**, **Deadline** — that explain the bulk of real-world mismatch cases. Liveliness, Ownership, Partition, TimeBasedFilter, and LatencyBudget are v0.3.x patches.
The analyzer covers the four MVP policies — **Reliability**, **Durability**, **History**, **Deadline** — that explain the bulk of real-world mismatch cases. Liveliness, Ownership, Partition, TimeBasedFilter, and LatencyBudget are v0.5.x patches.

---

Expand Down
46 changes: 28 additions & 18 deletions docs/projet-file/mcp-02-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,11 @@ ships in TopicForge releases, in this order:
Soft-breaking schema changes to `TopicInfo` and `HealthReport`
(additive optional fields). The 8-tool ceiling activates.
- **TopicForge v0.3.0 — real Cyclone + real Fast DDS + multi-vendor
framing (current sprint).** Replace the v0.2.0 Cyclone stub with the
framing (shipped).** Replaced the v0.2.0 Cyclone stub with the
actual CycloneDDS discovery (builtin DCPS readers for participants
and endpoints, QoS pair extraction, typed reader for samples on
builtin DCPS topics first ; arbitrary user-topic peek defers to a
v0.3.x patch). Ship `FastDdsAdapter` as a parallel OSS backend on
builtin DCPS topics first ; arbitrary user-topic peek deferred to
v0.4.0 Phase 1.5 — see entry below). Shipped `FastDdsAdapter` as a parallel OSS backend on
`fastdds` (listener-driven discovery via
`DomainParticipantListener`, RLock-protected state, bounded
discovery startup delay). Pyproject extras refactor:
Expand All @@ -346,15 +346,25 @@ ships in TopicForge releases, in this order:
statement and the OMG May 2025 interop reference. Same 3 MCP
tools ; vendor-neutral wire contract. Soft-breaking
`ParticipantInfo.vendor` Literal expansion to include `"fast"`.
- **TopicForge v0.3.x patches.** IDL/XTypes discovery to extend
`peek_dds_samples` to arbitrary user topics (today: builtin DCPS
topics only). Extended QoS coverage (Liveliness, Ownership,
Partition, TimeBasedFilter, LatencyBudget). Composite adapter
delegating per-tool category so users can run ROS2 + DDS surfaces
simultaneously.
- **TopicForge v0.4.0+ — `RtiConnextAdapter` in Pro tier.** Same
`_try_register_pro(mcp)` pattern. BYO RTI license. No new MCP
tools ; same 3 DDS tools, new backend.
- **TopicForge v0.4.0 — observability + composite + XTypes (shipped).**
IDL/XTypes discovery extended `peek_dds_samples` to arbitrary user
topics via best-effort Cyclone `cyclonedds.dynamic` decode (Phase 1.5)
with a `_decode_status` annotation (`"full"` / `"partial"` / `"raw"`).
`CompositeAdapter` (Phase 1) lifts the v0.3.0 single-adapter
limitation — ROS2 and DDS surfaces work simultaneously, routed by
tool category. 8-vendor auto-detect chain (Phase 1.5). Three new
observability tools shipped (`participant_events`, `topic_metrics`,
`peek_bag_samples`) — the v0.3.0 8-tool ceiling explicitly broken
three times, acknowledged inline.
- **TopicForge v0.5.x patches (planned).** Fast DDS `TypeObjectFactory`
binding completion to lift `_decode_status="raw"` → `"full"` on Fast
user topics. Extended QoS coverage (Liveliness, Ownership, Partition,
TimeBasedFilter, LatencyBudget). Real-bus validation of the v0.4.0
Phase 1.5 Cyclone XTypes pipeline against multi-vendor publishers.
- **TopicForge v0.4.0+ Pro tier — real `RtiConnextAdapter`.** Same
`_try_register_pro(mcp)` pattern. BYO RTI license. The v0.4.0 Phase 1.5
scaffold lives in `pro/` (gitignored) ; production binding pending
Pro tier launch. No new MCP tools ; same surface, new backend.

The earlier v0.2.0 / v0.3.0 split (proposed before the mono-MCP pivot
of 2026-05-14 and further refined by the multi-vendor framing when
Expand Down Expand Up @@ -508,11 +518,11 @@ decisions.
- **`cyclonedds` version pin.** Determined at v0.3.0 implementation
kickoff after a smoke test against the current bindings on
Windows + Linux.
- **QoS mismatch taxonomy.** Lock the policies checked at v0.3.0
(recommended: Reliability, Durability, History, Deadline — the four
that explain > 80 % of real-world *"subscriber doesn't receive"*
cases). Defer Liveliness, Ownership, Partition, TimeBasedFilter,
LatencyBudget to v0.3.x patches.
- **QoS mismatch taxonomy.** Resolved at v0.3.0 : Reliability,
Durability, History, Deadline ship — the four that explain > 80 %
of real-world *"subscriber doesn't receive"* cases. Liveliness,
Ownership, Partition, TimeBasedFilter, LatencyBudget deferred to
v0.5.x patches (still pending as of v0.5.0).
- **`peek_dds_samples` vs `sample_messages` description ergonomics.**
Verify in a Claude session that the LLM picks the right tool when
asked *"show me recent samples on topic /foo"* in a mixed ROS2+DDS
Expand Down Expand Up @@ -544,7 +554,7 @@ Resolved by the multi-vendor framing of 2026-05-14
importable on their host ; the new ordering only matters when
both SDKs are installed.

Newly open (resolve before v0.3.x patches) :
Newly open (resolve before v0.5.x patches) :

- ~~**XTypes / IDL discovery feasibility.**~~ **Resolved in v0.4.0
Phase 1 (2026-05-14).** `peek_dds_samples` on user-defined topics no
Expand Down
63 changes: 38 additions & 25 deletions src/topicforge/adapters/dds_cyclone/adapter.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
"""Cyclone DDS adapter — real implementation (v0.3.0).

Replaces the v0.2.0 stub with actual CycloneDDS discovery via the
`cyclonedds.builtin` builtin data readers. Joins the bus as a read-only
DDS-RTPS participant on the configured domain and observes every
conformant vendor on the wire — see `docs/dds-interop-matrix.md` for
the canonical multi-vendor positioning.

The 3 DDS methods (`list_participants`, `detect_qos_mismatches`,
`peek_dds_samples`) call into the CycloneDDS Python bindings ; the 4
ROS2 methods raise `AdapterError(DDS_ONLY_ERROR_MSG)` (this adapter is
DDS-only). The factory only loads this module when
`TOPICFORGE_DDS_BACKEND=cyclone` (or `auto` resolving to cyclone) — see
`services/factory.py`.

v0.3.0 scope:
* `list_participants` — full DCPSParticipant discovery via builtin reader
* `detect_qos_mismatches` — DCPSSubscription + DCPSPublication paired
by topic, run through the vendor-neutral pure analyzer in
`adapters/common/qos_analyzer.py`
* `peek_dds_samples` — works on the 4 builtin DCPS topics. Arbitrary
user topics require IDL/XTypes discovery and raise an `AdapterError`
pointing at the v0.3.x roadmap.
"""Cyclone DDS adapter — real implementation (v0.3.0+).

Joins the bus as a read-only DDS-RTPS participant on the configured
domain via the `cyclonedds.builtin` builtin data readers, and observes
every conformant vendor on the wire — see `docs/dds-interop-matrix.md`
for the canonical multi-vendor positioning.

The DDS / observability methods call into the CycloneDDS Python
bindings ; the ROS2 graph methods raise `AdapterError(DDS_ONLY_ERROR_MSG)`
(this adapter is DDS-only — pair with `Ros2CliAdapter` via the
v0.4.0 `CompositeAdapter` to get both surfaces simultaneously). The
factory only loads this module when `TOPICFORGE_DDS_BACKEND=cyclone`
(or `auto` resolving to cyclone) — see `services/factory.py`.

Current scope (v0.4.0+):

* `list_participants` — DCPSParticipant discovery via builtin reader,
enriched with `LifecycleBuffer` reconciliation (first/last seen,
status, seen_count).
* `detect_qos_mismatches` — DCPSSubscription + DCPSPublication paired by
topic, run through the vendor-neutral pure analyzer in
`adapters/common/qos_analyzer.py`.
* `peek_dds_samples` — full-fidelity on the 4 builtin DCPS topics ;
arbitrary user topics go through `_peek_user_topic` with best-effort
`cyclonedds.dynamic` XTypes decode (Phase 1.5). Each sample carries a
`_decode_status` annotation (`"full"` / `"partial"` / `"raw"`) so the
LLM caller knows whether to trust the payload or fall back on
`_raw_bytes_hex`.
* `participant_events` — `discovered` / `lost` events from the
`LifecycleBuffer`. Caveat : Cyclone updates the buffer only on
`list_participants` poll calls (no native at-discovery callbacks).
* `topic_metrics` — opportunistic frequency / sequence-gap / latency
metrics buffered as `peek_dds_samples` surfaces samples (no native
at-sample-receive callback in cyclonedds 2.6.x Python).

Sample-introspection helpers below are defensive against binding-version
shape variations — they read attributes via `getattr` with fallbacks and
Expand Down Expand Up @@ -96,8 +107,10 @@
_MAX_PARTICIPANTS = 256
_MAX_ENDPOINTS = 1024

# Builtin DCPS topics that `peek_dds_samples` can serve in v0.3.0.
# Arbitrary user topics require IDL/XTypes discovery (v0.3.x roadmap).
# Builtin DCPS topics that `peek_dds_samples` serves with full-fidelity
# structured payloads. Arbitrary user topics route through
# `_peek_user_topic` (Phase 1.5 best-effort XTypes decode → annotated
# raw-bytes fallback).
_BUILTIN_DCPS_TOPICS: dict[str, Any] = {
"DCPSParticipant": BuiltinTopicDcpsParticipant,
"DCPSSubscription": BuiltinTopicDcpsSubscription,
Expand Down
34 changes: 24 additions & 10 deletions src/topicforge/adapters/dds_fast/adapter.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
"""eProsima Fast DDS adapter — listener-driven discovery (v0.3.0).
"""eProsima Fast DDS adapter — listener-driven discovery (v0.3.0+).

Joins the bus as a read-only DDS-RTPS participant via the eProsima
Fast DDS Python bindings. A duck-typed listener accumulates discovery
state under an RLock so the public API methods read consistent
snapshots without racing the discovery callbacks.
snapshots without racing the discovery callbacks. Lifecycle events
(`discovered` / `lost`) are captured natively in
`on_participant_discovery` — no polling reconciliation needed,
contrasted with the Cyclone polling path.

See `docs/dds-interop-matrix.md` for the canonical multi-vendor
positioning. The factory only loads this module when
`TOPICFORGE_DDS_BACKEND=fast` (or `auto` resolving to fast) — see
`services/factory.py`.

v0.3.0 scope mirrors `CycloneDdsAdapter`:
* `list_participants` — snapshot of discovered participants
* `detect_qos_mismatches` — paired subs/pubs by topic + pure analyzer
* `peek_dds_samples` — builtin discovery snapshots (DCPSParticipant /
DCPSSubscription / DCPSPublication). Arbitrary user topics raise
`AdapterError` pointing at the v0.3.x XTypes/IDL roadmap.
`services/factory.py`. Pair with `Ros2CliAdapter` via the v0.4.0
`CompositeAdapter` to serve both ROS2 and DDS surfaces simultaneously.

Current scope (v0.4.0+) mirrors `CycloneDdsAdapter` :

* `list_participants` — snapshot of discovered participants enriched
with `LifecycleBuffer` fields (first/last seen, status, seen_count).
* `detect_qos_mismatches` — paired subs/pubs by topic + pure analyzer.
* `peek_dds_samples` — full-fidelity on the 4 builtin DCPS topics ;
arbitrary user topics return best-effort samples with a
`_decode_status` annotation. Fast DDS 2.6.x ships only a partial
dynamic XTypes Python surface, so the `"raw"` fallback with
`_raw_bytes_hex` is the common path on user topics until upstream
binding completion (v0.5.x patch).
* `participant_events` — `discovered` + `lost` from native listener
callbacks ; no polling required.
* `topic_metrics` — opportunistic metrics buffered as `peek_dds_samples`
surfaces samples (same caveat as Cyclone — no at-sample-receive
callback in fastdds 2.6.x Python).

Sample-introspection helpers are defensive against binding-version
shape variations — same convention as the Cyclone adapter's helpers.
Expand Down
7 changes: 3 additions & 4 deletions src/topicforge/adapters/ros2_mock/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from topicforge.adapters.base import AdapterError, AdapterName, EffectiveMode
from topicforge.adapters.ros2_mock import fixtures
from topicforge.constants import MAX_SAMPLE_COUNT
from topicforge.models import (
BagAnalysis,
MessageSample,
Expand All @@ -27,8 +28,6 @@
# first ROS2 install.
_BAG_EXTENSIONS: frozenset[str] = frozenset({".mcap", ".db3", ".bag"})

_MAX_SAMPLE_COUNT = 50


class MockAdapter:
name: AdapterName = "mock"
Expand Down Expand Up @@ -66,7 +65,7 @@ def peek_bag_samples(self, path: str, topic: str, count: int) -> SampleResult:
if count < 0:
raise AdapterError("count must be >= 0")
_reject_non_bag_path(path)
clamped = min(count, _MAX_SAMPLE_COUNT)
clamped = min(count, MAX_SAMPLE_COUNT)
samples = fixtures.mock_bag_samples_for(topic, clamped)
return SampleResult(
topic=topic,
Expand Down Expand Up @@ -98,7 +97,7 @@ def peek_dds_samples(self, topic: str, count: int) -> SampleResult:
f"Unknown DDS topic: {topic!r}. Known mock DDS topics: "
f"{list(fixtures.MOCK_DDS_TOPICS)}"
)
clamped = min(count, _MAX_SAMPLE_COUNT)
clamped = min(count, MAX_SAMPLE_COUNT)
return fixtures.mock_dds_samples_for(topic, clamped)

def participant_events(
Expand Down
28 changes: 28 additions & 0 deletions src/topicforge/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Package-wide constants shared across tools, services, and adapters.

Layer-neutral home for values consumed across the whole package : tool
handlers cap arguments here, services read them when constructing
clamped slices, adapters reference them when sizing their internal
buffers. Keeping the home at the root level (rather than under
`services/`) avoids reverse-layer imports (`adapters/` would otherwise
have to reach into `services.constants` and trigger a circular load
through `services/__init__.py`).

History : v0.2.0 moved `MAX_SAMPLE_COUNT` out of `services.inspector`
into `services.constants` (audit-2026-05-14 item A4). v0.5.x relocated
to this root module so the mock adapter and bag service can reuse it
without crossing the services→adapters layer in reverse.

If a constant graduates to runtime-configurable, move it onto
`Settings` and update callers accordingly.
"""

from __future__ import annotations

# Server-side cap on `sample_messages`, `peek_dds_samples`, and
# `peek_bag_samples` count parameters. Surfaced to clients via
# `HealthReport.max_sample_count`. Requests above this value are
# silently clamped to keep tool output bounded ; clients sizing their
# requests proactively should read the cap from the health endpoint
# rather than hardcoding it.
MAX_SAMPLE_COUNT = 50
5 changes: 2 additions & 3 deletions src/topicforge/services/bag_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
annotate_partial,
annotate_raw,
)
from topicforge.constants import MAX_SAMPLE_COUNT
from topicforge.models import (
BagAnalysis,
BagTopicStats,
Expand All @@ -45,8 +46,6 @@
".db3": "db3",
".bag": "bag",
}

_MAX_SAMPLE_COUNT = 50
_ROSBAGS_REQUIRED_MSG = (
"Bag analysis with full sample decode requires the `rosbags` library. "
"Install via `pip install topicforge[bags]` and retry. "
Expand Down Expand Up @@ -154,7 +153,7 @@ def peek_samples(
if not resolved.exists():
raise AdapterError(f"bag path does not exist: {path!r}")

clamped = min(count, _MAX_SAMPLE_COUNT)
clamped = min(count, MAX_SAMPLE_COUNT)
try:
samples = _peek_with_rosbags(resolved, topic, clamped)
except AdapterError:
Expand Down
21 changes: 0 additions & 21 deletions src/topicforge/services/constants.py

This file was deleted.

2 changes: 1 addition & 1 deletion src/topicforge/services/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
# `topicforge-pro` package still sees `middleware_available=True` —
# they just need the Pro package to actually use it.
from topicforge.config.settings import _DDS_BACKEND_MODULES
from topicforge.constants import MAX_SAMPLE_COUNT
from topicforge.models import HealthReport
from topicforge.services.constants import MAX_SAMPLE_COUNT

_HEALTH_FALLBACK_MODULES: dict[str, str] = {
"rti": "rti.connextdds",
Expand Down
8 changes: 2 additions & 6 deletions src/topicforge/services/inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pathlib import Path

from topicforge.adapters.base import AdapterError, AdapterName, MiddlewareAdapter
from topicforge.constants import MAX_SAMPLE_COUNT
Comment on lines 12 to +13
from topicforge.models import (
BagAnalysis,
MismatchReport,
Expand All @@ -19,7 +20,6 @@
TopicInfo,
TopicMetrics,
)
from topicforge.services.constants import MAX_SAMPLE_COUNT

DEFAULT_SAMPLE_COUNT = 5
DEFAULT_LOOKBACK_SECONDS = 300
Expand All @@ -31,11 +31,7 @@
_WINDOW_MIN = 1
_WINDOW_MAX = 3600

# Re-export for backward-compatibility with v0.1.x code that imports
# `MAX_SAMPLE_COUNT` from `topicforge.services.inspector`. The canonical
# home is now `topicforge.services.constants` ; new code should import
# from there.
__all__ = ["DEFAULT_SAMPLE_COUNT", "MAX_SAMPLE_COUNT", "Inspector"]
__all__ = ["DEFAULT_SAMPLE_COUNT", "Inspector"]

# Strict allowlist mirroring ROS2 topic-name conventions:
# * must start with `/`
Expand Down
2 changes: 1 addition & 1 deletion tests/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from __future__ import annotations

from topicforge.config import Settings
from topicforge.constants import MAX_SAMPLE_COUNT
from topicforge.services import HealthService
from topicforge.services.inspector import MAX_SAMPLE_COUNT


def test_health_report_in_mock_mode() -> None:
Expand Down
Loading