revert(drivers): remove the periodic 5-minute device re-browse (#523)

Removes the tag-set drift detector shipped in e77c8a35. The design was sound
-- the tautology drivers were correctly gated out, and the structurally
undetectable "vanished" direction was declared rather than faked -- but none of
that is the objection. The feature browsed real plant equipment on a recurring
timer, at a frequency no operator could configure (the interval was a
constructor parameter with no appsettings key), and that outbound traffic was
never once observed against a real PLC, CNC or MTConnect Agent.

Removed: TagSetDrift/TagSetDriftDetector; DriverInstanceActor's drift timer,
tick message, Props/ctor parameter, gate, handler and leaf-collector;
ITagDiscovery.AuthoredDiscoveryRefs + .DiscoveryStreamIncludesAuthoredTags and
their four implementations; 14 tests.

Kept: ITagDiscovery.SupportsOnlineDiscovery (it predates this and drives the
universal browse picker), and the whole IRediscoverable consumption from
09a401b8 -- the driver tells us, we do not poll.

CONSEQUENCE, deliberately accepted: AbCip and FOCAS browse a live backend but
implement no IRediscoverable, so they now have NO tag-change signal at all -- a
PLC re-download is invisible until someone re-browses by hand. Recorded in
CLAUDE.md so the next reader does not have to rediscover it, along with the
shape to reach for if that coverage is wanted back (event-driven, or triggered
by a reconnect/deploy, not a wall-clock timer).

Runtime.Tests 476 pass / 13 skipped (skip set unchanged); FOCAS 275, TwinCAT
192, MTConnect 491, AbCip 342, Core.Abstractions 266 all green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-28 13:59:06 -04:00
parent 16752032e5
commit 5fff597324
11 changed files with 59 additions and 743 deletions
@@ -38,32 +38,4 @@ public interface ITagDiscovery
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
/// </summary>
bool SupportsOnlineDiscovery => false;
/// <summary>
/// The device-native references this driver's <b>authored</b> tags bind to — the same vocabulary
/// <see cref="DiscoverAsync"/> streams as <see cref="DriverAttributeInfo.FullName"/>. Comparing the
/// two sets detects that the remote's tag set has drifted from what an operator authored.
/// <para><b>Null means "I do not report this", and drift detection is skipped.</b> That is the
/// default on purpose: an empty collection legitimately means "no tags authored, so everything
/// discovered is new", and the two must not be confused. A driver opting in is asserting that its
/// authored refs are directly comparable to what it discovers.</para>
/// <para>Only consulted when <see cref="SupportsOnlineDiscovery"/> is true. For a driver that
/// replays authored tags from config rather than browsing the backend, the comparison is a
/// tautology — it would report "no drift" forever and read as reassurance.</para>
/// </summary>
IReadOnlyCollection<string>? AuthoredDiscoveryRefs => null;
/// <summary>
/// True when <see cref="DiscoverAsync"/> re-emits this driver's <b>authored</b> tags into the same
/// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker
/// shows an operator their existing tags alongside what the device offers.
/// <para><b>This makes one half of drift detection structurally undetectable.</b> If authored tags
/// are always re-emitted then the discovered set always contains the authored set, so an authored tag
/// that has VANISHED from the device can never appear missing. Declaring it here means the detector
/// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading
/// as reassurance.</para>
/// <para>False (the default) means the stream is purely device-derived — as MTConnect's is, built
/// from the Agent's probe model — so both directions are detectable.</para>
/// </summary>
bool DiscoveryStreamIncludesAuthoredTags => false;
}
@@ -1036,17 +1036,6 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>Pre-declared tags are emitted under their <c>Name</c>, which is the driver FullName the
/// controller-browse leaves also use.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_declaredTags.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed controller symbols AND re-emits every
/// pre-declared tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -517,17 +517,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// until the node set is non-empty and stable.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> IS its driver-side FullName — <c>DiscoverAsync</c> emits
/// <c>FullName: tag.Name</c> for every authored tag — so the two sets are directly comparable.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits the device-derived FixedTree AND re-emits every authored
/// tag, so an authored tag that vanished from the CNC cannot appear missing.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -907,19 +907,6 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// </remarks>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The DataItem ids the authored raw tags bind. <c>DiscoverAsync</c> emits
/// <c>FullName: dataItem.Id</c> from the Agent's probe model, so the two sets are the same
/// vocabulary.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
Volatile.Read(ref _binding).RawPathsByDataItemId.Keys;
/// <inheritdoc />
/// <remarks>False — <c>DiscoverAsync</c> is built purely from the Agent's probe model and never
/// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as
/// missing. MTConnect is currently the only driver where both drift directions are detectable.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => false;
/// <inheritdoc/>
/// <remarks>
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
@@ -404,17 +404,6 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc />
/// <remarks>The authored tag's <c>Name</c> is its RawPath and its driver FullName
/// (<c>DiscoverAsync</c> emits <c>FullName: tag.Name</c>), directly comparable to the browsed
/// symbols' <c>InstancePath</c>.</remarks>
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
/// <inheritdoc />
/// <remarks>True: <c>DiscoverAsync</c> emits browsed ADS symbols AND re-emits every authored tag.</remarks>
public bool DiscoveryStreamIncludesAuthoredTags => true;
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
@@ -1,6 +1,5 @@
using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -33,11 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
/// <summary>Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
/// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime
/// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens.</summary>
public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
public sealed record InitializeRequested(string DriverConfigJson);
public sealed record InitializeSucceeded(int Generation);
public sealed record InitializeFailed(string Reason, int Generation);
@@ -127,15 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// between connects), and dropping it in one state would lose the signal silently.</summary>
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
/// <summary>Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
/// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
/// <see cref="QualifiesForDriftCheck"/>.</summary>
private sealed record DriftCheckTick
{
public static readonly DriftCheckTick Instance = new();
private DriftCheckTick() { }
}
public sealed class RetryConnect
{
public static readonly RetryConnect Instance = new();
@@ -196,14 +181,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private DateTime? _rediscoveryNeededUtc;
private string? _rediscoveryReason;
/// <summary>Period of the tag-set drift check; tests inject a tiny value.</summary>
private readonly TimeSpan _driftCheckInterval;
/// <summary>Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every
/// check. Without this the operator's prompt would re-stamp its timestamp every interval forever,
/// which reads as "it just happened again" rather than "it is still true".</summary>
private string? _lastDriftSignature;
/// <summary>The references the host wants kept subscribed (set by <see cref="SetDesiredSubscriptions"/>).
/// Re-applied on every entry into <c>Connected</c> so values resume after a reconnect or redeploy.</summary>
private IReadOnlyList<string> _desiredRefs = Array.Empty<string>();
@@ -237,8 +214,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// defaults to an empty string when not provided (e.g. in unit tests).</param>
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
/// <param name="driftCheckInterval">Optional period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>. Tests inject a tiny value so the check runs without a wait.</param>
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
@@ -250,8 +225,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null) =>
TimeSpan? healthPollInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver,
reconnectInterval ?? DefaultReconnectInterval,
@@ -259,8 +233,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
healthPublisher ?? NullDriverHealthPublisher.Instance,
clusterId ?? string.Empty,
invoker,
healthPollInterval,
driftCheckInterval));
healthPollInterval));
/// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -291,8 +264,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
/// <param name="driftCheckInterval">Period of the tag-set drift check; defaults to
/// <see cref="DefaultDriftCheckInterval"/>.</param>
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
public DriverInstanceActor(
@@ -302,15 +273,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
IDriverHealthPublisher? healthPublisher = null,
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null,
TimeSpan? driftCheckInterval = null)
TimeSpan? healthPollInterval = null)
{
_driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
_driverInstanceId = driver.DriverInstanceId;
_clusterId = clusterId ?? string.Empty;
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
_reconnectInterval = reconnectInterval;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
@@ -381,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
StartDriftCheck();
});
Receive<InitializeFailed>(msg =>
{
@@ -427,7 +395,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_driverInstanceId, msg.Reason);
DetachSubscription();
RecordFault();
StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
@@ -435,7 +402,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
DetachSubscription();
StopDriftCheck();
Become(Reconnecting);
PublishHealthSnapshot();
});
@@ -458,7 +424,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
SubscribeDesiredAlarms();
});
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
Receive<DataChangeForward>(OnDataChangeForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
@@ -553,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
StartDriftCheck();
});
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
// current config; only a (generation-matched) InitializeSucceeded transitions state.
@@ -798,10 +762,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Stops the drift check. Called on every Connected exit — browsing a disconnected driver would
/// fail every pass, and a failed browse is deliberately NOT treated as drift.</summary>
private void StopDriftCheck() => Timers.Cancel("drift-check");
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
/// push data-change / alarm events to an actor that has lost its driver connection.</summary>
@@ -849,119 +809,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_rediscoveryHandler = null;
}
/// <summary>
/// True when this driver is worth drift-checking: it must genuinely BROWSE its remote
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) and must report the refs its authored tags
/// bind (<see cref="ITagDiscovery.AuthoredDiscoveryRefs"/>).
/// <para>The first condition is what makes the check meaningful rather than reassuring. Most drivers
/// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of <c>DiscoverAsync</c>
/// rather than enumerating the device, so diffing the two sets for them is a tautology that would
/// report "no drift" forever. A check that cannot fail is worse than no check: it looks like
/// coverage.</para>
/// </summary>
private bool QualifiesForDriftCheck() =>
_driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null };
/// <summary>Starts the slow drift check on a Connected entry, for qualifying drivers only.</summary>
private void StartDriftCheck()
{
if (!QualifiesForDriftCheck()) return;
// Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after
// connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and
// report phantom drift on every reconnect.
Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval);
}
/// <summary>
/// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises
/// the same operator prompt an <see cref="IRediscoverable"/> raise does — this is the signal for the
/// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic
/// compare is the only way to notice a PLC program re-download.
/// <para>Advisory, like every rediscovery signal: the served address space is not touched. Raw tags
/// are authored through the <c>/raw</c> browse-commit flow, so an operator re-browses and commits.</para>
/// </summary>
private async Task HandleDriftCheckAsync(DriftCheckTick _)
{
if (_driver is not ITagDiscovery discovery) return;
var authored = discovery.AuthoredDiscoveryRefs;
if (authored is null) return;
CapturedTree tree;
try
{
var builder = new CapturingAddressSpaceBuilder();
// Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would
// block writes, reconnects and health polls behind it.
using var cts = new CancellationTokenSource(DriftBrowseTimeout);
await _invoker.ExecuteAsync(
DriverCapability.Discover,
_driverInstanceId,
async ct => await discovery.DiscoverAsync(builder, ct),
cts.Token);
tree = builder.Build();
}
catch (Exception ex)
{
// A failed browse is not drift — the device may simply be unreachable, which the health surface
// already reports. Reporting drift here would turn every comms blip into "all your tags vanished".
_log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId);
return;
}
if (tree.Truncated)
{
// A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished.
_log.Warning(
"DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial",
_driverInstanceId);
return;
}
var discovered = new List<string>();
CollectLeafRefs(tree.Root, discovered);
var drift = TagSetDriftDetector.Compare(
discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags);
if (!drift.HasDrift)
{
// Recovered: the device matches the authored set again, so drop the prompt and let the next
// genuine drift re-raise with a fresh timestamp.
if (_lastDriftSignature is not null)
{
_log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set",
_driverInstanceId);
_lastDriftSignature = null;
_rediscoveryNeededUtc = null;
_rediscoveryReason = null;
PublishHealthSnapshot();
}
return;
}
// Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read
// as "it just happened again" rather than "it is still true".
if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return;
_lastDriftSignature = drift.Signature;
_rediscoveryNeededUtc = DateTime.UtcNow;
_rediscoveryReason = "device tag set differs from authored: " + drift.Describe();
_log.Info(
"DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged",
_driverInstanceId, drift.Describe());
PublishHealthSnapshot();
}
/// <summary>Per-pass timeout for the drift browse. Bounds the mailbox suspension.</summary>
private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30);
/// <summary>Flattens a captured tree to its leaf ids — the driver-side <c>FullName</c>s, which is the
/// vocabulary <c>AuthoredDiscoveryRefs</c> is defined in.</summary>
private static void CollectLeafRefs(CapturedNode node, List<string> into)
{
if (node.IsLeaf) into.Add(node.Id);
foreach (var child in node.Children) CollectLeafRefs(child, into);
}
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
@@ -1137,7 +984,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <inheritdoc />
protected override void PostStop()
{
StopDriftCheck();
DetachSubscription();
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
@@ -1,92 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// The difference between what a driver <b>discovers</b> on its remote and what an operator
/// <b>authored</b> — i.e. whether the device's tag set has moved out from under the deployed config.
/// </summary>
/// <param name="Appeared">Refs the remote now offers that no authored tag binds.</param>
/// <param name="Vanished">
/// Refs authored tags bind that the remote no longer offers. <b>Always empty when
/// <paramref name="VanishedDetectable"/> is false</b> — see that parameter.
/// </param>
/// <param name="VanishedDetectable">
/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived
/// ones (<c>ITagDiscovery.DiscoveryStreamIncludesAuthoredTags</c>). The discovered set then always
/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so
/// <see cref="Describe"/> can say "new-on-device only" rather than implying a clean bill of health.
/// </param>
public sealed record TagSetDrift(
IReadOnlyList<string> Appeared,
IReadOnlyList<string> Vanished,
bool VanishedDetectable)
{
/// <summary>True when the two sets differ in either direction.</summary>
public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0;
/// <summary>
/// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an
/// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical
/// drift look new.
/// </summary>
public string Signature { get; } =
string.Join('', Appeared) + '' + string.Join('', Vanished);
/// <summary>An operator-facing one-liner naming what moved, suitable for a UI tooltip.</summary>
/// <returns>A short human-readable description, or "no drift" when there is none.</returns>
public string Describe()
{
if (!HasDrift) return "no drift";
var parts = new List<string>(3);
if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})");
if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})");
// Say so rather than let the absence of a "missing" clause read as "nothing is missing".
if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver");
return string.Join("; ", parts);
}
/// <summary>Names at most two refs so the message stays readable when a whole PLC program is swapped.</summary>
private static string Sample(IReadOnlyList<string> refs) =>
refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more";
}
/// <summary>
/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the
/// actor's periodic check stays cheap; kept out of <c>DriverInstanceActor</c> so it is directly testable
/// without an actor system.
/// </summary>
public static class TagSetDriftDetector
{
/// <summary>
/// Computes the two-way difference.
/// <para><b>Ordinal comparison</b>, matching how every driver keys its tag tables — a device that
/// genuinely exposes both <c>Speed</c> and <c>speed</c> must not have them collapsed.</para>
/// </summary>
/// <param name="discovered">Refs streamed by the driver's most recent discovery pass.</param>
/// <param name="authored">Refs the driver's authored tags bind (<c>ITagDiscovery.AuthoredDiscoveryRefs</c>).</param>
/// <param name="detectVanished">
/// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag
/// structurally invisible. The vanished half is then not computed at all rather than computed to a
/// misleading empty — see <see cref="TagSetDrift.VanishedDetectable"/>.
/// </param>
/// <returns>The drift, which may be empty.</returns>
public static TagSetDrift Compare(
IReadOnlyCollection<string> discovered,
IReadOnlyCollection<string> authored,
bool detectVanished = true)
{
ArgumentNullException.ThrowIfNull(discovered);
ArgumentNullException.ThrowIfNull(authored);
var authoredSet = new HashSet<string>(authored, StringComparer.Ordinal);
var discoveredSet = new HashSet<string>(discovered, StringComparer.Ordinal);
var appeared = discoveredSet.Where(r => !authoredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray();
var vanished = detectVanished
? authoredSet.Where(r => !discoveredSet.Contains(r))
.OrderBy(r => r, StringComparer.Ordinal).ToArray()
: [];
return new TagSetDrift(appeared, vanished, detectVanished);
}
}