Skip to content

feat: depth-aware channel derivation (leaf-preserving truncation) - #6

Merged
mbreissi merged 1 commit into
mainfrom
feat/channel-depth
Jul 28, 2026
Merged

feat: depth-aware channel derivation (leaf-preserving truncation)#6
mbreissi merged 1 commit into
mainfrom
feat/channel-depth

Conversation

@mbreissi

Copy link
Copy Markdown
Contributor

The bug

MTConnect component paths go deeper than a UNS topic can carry. The live demo Mazak's stock data
item sits on Resources[resources]/Materials[materials]/Stock[stock], which with its id is four
channel tokens — an instance-scoped data topic (ecv1/{device}/{component}/{instance}/data/…) has
already spent four of the seven allowed / separators, so it has room for three. Under
selection: { "mode": "all" } the library refused the topic with DEPTH_EXCEEDED and the signal
never published at all.

The rule

A derived channel is the last k UNS-sanitized component-path segments plus the signal id, where
k is the largest value that fits the instance's real UNS budget.

Component path Derived channel
(device level) stock
Controller[controller] controller-controller/estop
Axes/Linear[X] axes/linear-x/xabs
Resources[resources]/Materials[materials]/Stock[stock] materials-materials/stock-stock/stock
Systems[systems]/Hydraulic[hydraulic]/Pump[pump]/Motor[motor]/Sensor[sensor] motor-motor/sensor-sensor/ptemp
  • Root-side segments drop first. The leaf-most ones say what the signal is;
    Resources[resources] above Materials[materials]/Stock[stock] barely narrows anything.
  • The id is terminal and never dropped. That is what makes truncation safe: signal ids are
    unique per instance (validate_bindings enforces it for the explicit half, the -2/-3 suffix
    chain for the derived half), so however much path is dropped two derived channels cannot collide.
    No extra suffix rule is needed and none was added — there is a test that asserts uniqueness at
    every k.
  • The budget is measured, not assumed. app::channel_budget_of mints a probe topic through the
    library's own Uns builder and takes what the prefix did not spend, so MAX_TOPIC_SLASHES (7)
    and MAX_TOPIC_UTF8_BYTES (256) are never copied into this repo. It is per instance and
    includes the device/component/instance token lengths, so a verbose identity costs channel depth
    rather than silently breaking the topic. src/mtconnect/** stays EdgeCommons-free: it receives a
    plain ChannelBudget { max_tokens, max_bytes }.
  • Nothing is lost. The full, untruncated component path stays in the ProbeModel and is served
    as signal.address.componentPath on sb/signals and on every sb/browse entry. Only the topic
    is shaped.

Observability

Ordinary truncation is normal derivation on a deep machine: counted in
ServedSet::channel_truncated and logged once per recompile at DEBUG — no event. The only
warning is the pathological floor, where not even the bare id fits (the identity itself has consumed
the topic): that raises the existing MtconnectSignalSetEvent with reason: "channelBudget", once
per distinct count, and leaves the channel as the bare id so the library's own validation refuses it
loudly on publish.

A hand-set channel on an explicit signals[] entry is untouched, however deep. An explicit
entry that omits channel takes the derived one and is shaped like any other.

Plumbing

ChannelBudgets resolves once at startup (identity is fixed for the process; adding an instance is
already RESTART_REQUIRED) and is stamped by both compile_mtconnect and
SignalRegistry::new, so the startup compile and every live reload derive identical channels. The
budget is hashed into the signal-set generation, because it changes what every derived channel is
and therefore what a browse cursor was minted against.

Live evidence

Run against the real demo.mtconnect.org Mazak with mode: "all", HOST/MQTT into a local broker.
The signal that used to be dropped now publishes:

ecv1/gw-01/MtconnectAdapter/cnc-1/data/materials-materials/stock-stock/stock

134 distinct topics observed, every one within budget; no publish failed / DEPTH_EXCEEDED in the
adapter log. Other deep paths on the same device shaped the same way
(systems-systems/coolant-coolant/cooltemp, controller-controller/path-path/execution, …).

Gates

  • cargo test377 passed, 0 failed (+15 new: 8 unit in selection.rs, 5 in app.rs, 2
    end-to-end in poll_acquisition.rs; 2 more in reload.rs).
  • cargo clippy --all-targets -- -D warnings — clean.
  • cargo llvm-cov --fail-under-lines 90 (CI's exclusions) — 96.71% total;
    selection.rs 97.92%, app.rs 95.28%, reload.rs 98.30%.

New fixtures: tests/fixtures/devices_deep_2.7.xml (the demo Mazak stock shape reproduced
verbatim, plus a five-level path and a long-token path that exhausts bytes before levels) and
its /current companion.

Docs

DESIGN.md gains D-MtconnectAdapter-L12 (supersedes L10's channel clause);
docs/reference/configuration.md gains a Deep component paths section;
docs/explanation.md gains A machine model is deeper than a topic.

MTConnect component paths go deeper than a UNS topic can carry. The demo
Mazak's `stock` sits on Resources[resources]/Materials[materials]/Stock[stock]
- four channel tokens where an instance-scoped data topic has room for three -
so under `selection: { "mode": "all" }` the library refused its topic with
DEPTH_EXCEEDED and the signal never published at all.

A derived channel is now the LAST k component-path segments plus the signal id,
with k the largest value that fits the instance's real UNS budget. The leaf-most
segments are the informative ones, so root-side segments drop first; the id is
terminal and never dropped, which is what keeps every derived channel unique
(signal ids are unique per instance - enforced by validate_bindings for the
explicit half, by the -2/-3 suffix chain for the derived half).

The budget is measured, not assumed: `app::channel_budget_of` mints a probe
topic through the library's own `Uns` builder and takes what the prefix did not
spend, so the 7-separator and 256-byte limits are never copied here. It is
per instance and includes the device/component/instance token lengths, so a
verbose identity costs channel depth instead of silently breaking the topic.
`ChannelBudgets` resolves once at startup and is stamped by both
`compile_mtconnect` and `SignalRegistry::new`, so startup and every reload
derive identical channels; it is hashed into the signal-set generation.

Nothing is lost: the full component path stays in the probe model and is served
as `signal.address.componentPath` on sb/signals and sb/browse.

Ordinary truncation is normal derivation on a deep machine - counted in
`ServedSet::channel_truncated`, logged at DEBUG, no event. The only warning is
the pathological floor, where not even the bare id fits: that raises the
existing MtconnectSignalSetEvent with reason "channelBudget" and leaves the
channel as the id, so the library's own validation refuses it loudly on publish.
A hand-set `channel` is untouched however deep.

Verified live against demo.mtconnect.org: the Mazak stock signal now publishes
on
  ecv1/gw-01/MtconnectAdapter/cnc-1/data/materials-materials/stock-stock/stock

Design: DESIGN.md D-MtconnectAdapter-L12.
@mbreissi
mbreissi merged commit e777c19 into main Jul 28, 2026
4 checks passed
@mbreissi
mbreissi deleted the feat/channel-depth branch July 28, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant