feat(core): InputQuality.WorstOf — the one worst-of-input rule (archreview R3-02 contract)

This commit is contained in:
Joseph Doherty
2026-08-20 09:38:16 -04:00
parent 72329eabdd
commit faea4ebfca
5 changed files with 202 additions and 19 deletions
@@ -580,3 +580,23 @@ T17 anytime; T18 after all; T19 last
```
Overall effort: **~2 dev-days code+unit, plus the rig session**. The three HIGH-RISK data-plane slices (T3 MQTT stale, T8 value-node Bad, T13/T16 hold-on-Bad) each land behind their full suites; the two recovery contract tests (T10, T16's identical-value case) must each be seen to FAIL once against a reverted dedup before merge — that is the whole reason this family re-appeared.
---
## Execution notes
Branch `fix/r3-02-stale-good-family` (worktree `.claude/worktrees/agent-a696a39981023fa0b`), off master
`72329eab`. Executed in plan block order T1, B (T2–T5), C (T6–T10), D (T12–T14), E (T15–T16), T17, T18.
**T11 and T19 are deliberately NOT in this branch** — T11 waits for the sibling R3-03 branch to merge
(it is the only edit that would touch `DriverInstanceActor` lifecycle alongside R3-03's restart work),
and T19 is the operator rig gate.
Red-before observations are recorded per task below: what was run, and the failure text that proves the
test could only pass once the production change landed.
### T1 — `InputQuality.WorstOf`
- RED: `dotnet test tests/Core/…Core.Abstractions.Tests --filter "FullyQualifiedName~InputQualityTests"`
⇒ **build failure**, 8 × `error CS0103: The name 'InputQuality' does not exist in the current context`.
- GREEN: 13 passed. `Core.ScriptedAlarms.Tests` 94/94 green with `WorstInputStatus` re-pointed (the
never-pushed/null skips stay in the alarm engine — they are alarm policy, not the shared rule).
@@ -5,7 +5,7 @@
{
"id": "T1",
"subject": "InputQuality.WorstOf shared helper (Core.Abstractions) + truth-table tests; re-point ScriptedAlarmEngine.WorstInputStatus",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
@@ -0,0 +1,74 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// The one worst-of-input rule every derived-value seam uses — scripted alarms, virtual tags and
/// the Calculation driver.
///
/// <para><b>The contract it implements</b> (archreview R3-02, "the stale-Good family"): a value
/// consumer never reports a quality better than its source's. A node derived from driver values
/// carries, at worst, the worst status among its inputs — a Bad input makes the derived node Bad
/// with that input's sub-code, an Uncertain input makes it Uncertain, and a source that has
/// stopped communicating is a Bad input for everything downstream of it.</para>
///
/// <para><b>Why it lives here.</b> Core.Abstractions is the one assembly all three seams already
/// reference (Commons, Runtime and the driver assemblies all sit above it), and
/// <see cref="DataValueSnapshot"/> — the shape the rule reads — is declared beside it. Stating
/// the rule three times is how the three seams drifted apart in the first place.</para>
///
/// <para><b>Severity, not equality.</b> Comparison is on the OPC UA Part 4 severity bits (the top
/// two: <c>00</c> Good, <c>01</c> Uncertain, <c>10</c>/<c>11</c> Bad); the sub-code below them is
/// carried through verbatim because the sub-code is the diagnostic
/// (<c>BadNoCommunication</c> = go look at the device, <c>BadWaitingForInitialData</c> = it never
/// arrived, <c>BadInternalError</c> = the script).</para>
/// </summary>
public static class InputQuality
{
/// <summary>
/// No value has ever been received for this input — the status a fresh node is materialised
/// with, and what <see cref="WorstOf"/> reports for a dependency slot that holds no snapshot
/// at all. Spelled here rather than taken from <c>Commons.OpcUa.OpcUaStatus</c> because
/// Core.Abstractions sits <em>below</em> Commons and deliberately references nothing.
/// </summary>
public const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>
/// The worst status among <paramref name="inputs"/>: the highest Part 4 severity, carrying
/// that input's own sub-code. A <see langword="null"/> entry is a dependency that has never
/// been pushed and reports <see cref="BadWaitingForInitialData"/> — reporting Good for it is
/// the laundering this helper exists to stop. An empty set is Good: there is nothing to be
/// worse than.
/// </summary>
/// <param name="inputs">The dependency snapshots, in a stable order.</param>
/// <returns>The worst status code observed.</returns>
/// <remarks>
/// Among <em>equal</em> severity the first entry seen wins, so a re-evaluation over the same
/// inputs reports the same sub-code instead of oscillating between two equally-bad codes.
/// </remarks>
public static uint WorstOf(IEnumerable<DataValueSnapshot?> inputs)
{
ArgumentNullException.ThrowIfNull(inputs);
var worst = 0u;
var worstSeverity = 0u;
foreach (var input in inputs)
{
var status = input?.StatusCode ?? BadWaitingForInitialData;
var severity = status >> 30;
if (severity <= worstSeverity) continue;
worstSeverity = severity;
worst = status;
}
return worst;
}
/// <summary>
/// True when a consumer may evaluate against a value carrying <paramref name="statusCode"/>.
/// Good and Uncertain are usable — an Uncertain value is present and a script can make a
/// qualified decision from it; Bad is not, and a consumer holds instead (the alarm engine's
/// long-standing policy, generalised).
/// </summary>
/// <param name="statusCode">The status code to classify.</param>
/// <returns><see langword="true"/> for Good or Uncertain severity.</returns>
public static bool IsUsable(uint statusCode) => (statusCode >> 30) < 2;
}
@@ -919,26 +919,18 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
/// observation.</para></summary>
/// observation.</para>
/// <para><b>The comparison itself lives in <see cref="InputQuality.WorstOf"/></b> (archreview R3-02)
/// — the same rule the virtual-tag and Calculation seams now use, so "worst input wins, carrying its
/// sub-code, first-seen on a severity tie" is stated once. What stays here is the alarm-specific
/// <em>filter</em>: this engine's two skips are deliberately NOT part of the shared rule, because the
/// other two seams treat a never-pushed input as Bad rather than as absent.</para></summary>
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
{
uint worst = 0u;
var worstSeverity = 0u;
foreach (var kv in cache)
{
// No entry at all — nothing was observed, so there is nothing to report on.
if (kv.Value is null) continue;
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
if (kv.Value.StatusCode == StatusNeverPushed) continue;
var status = kv.Value.StatusCode;
var severity = status >> 30;
if (severity > worstSeverity)
{
worstSeverity = severity;
worst = status;
}
}
return worst;
// No entry at all — nothing was observed, so there is nothing to report on.
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
var observed = cache.Values.Where(v => v is not null && v.StatusCode != StatusNeverPushed);
return InputQuality.WorstOf(observed);
}
/// <summary>Update the tracked worst-quality bucket for an alarm; return true iff the 3-state bucket
@@ -0,0 +1,97 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Truth table for <see cref="InputQuality"/> — the one worst-of-input rule every derived-value
/// seam uses (scripted alarms, virtual tags, the Calculation driver). The quality contract
/// (archreview R3-02) is "a value consumer never reports a quality better than its source's",
/// and this type is the single place that decides what "worse" means, so a divergence here is a
/// divergence in every seam at once.
/// </summary>
public sealed class InputQualityTests
{
private const uint Good = 0x00000000u;
private const uint Uncertain = 0x40000000u;
private const uint UncertainLastUsableValue = 0x40900000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadInternalError = 0x80020000u;
private const uint BadWaitingForInitialData = 0x80320000u;
private static DataValueSnapshot Snap(object? value, uint status) =>
new(value, status, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow);
/// <summary>An empty input set reports Good — there is nothing to be worse than.</summary>
[Fact]
public void WorstOf_no_inputs_is_Good()
{
InputQuality.WorstOf([]).ShouldBe(Good);
}
/// <summary>All-Good inputs report Good.</summary>
[Fact]
public void WorstOf_all_good_is_Good()
{
InputQuality.WorstOf([Snap(1, Good), Snap(2, Good)]).ShouldBe(Good);
}
/// <summary>A single Uncertain input carries its own sub-code out, not the generic Uncertain.</summary>
[Fact]
public void WorstOf_propagates_the_uncertain_sub_code()
{
InputQuality.WorstOf([Snap(1, Good), Snap(2, UncertainLastUsableValue)])
.ShouldBe(UncertainLastUsableValue);
}
/// <summary>Bad beats Uncertain regardless of the order the inputs are seen in.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void WorstOf_bad_beats_uncertain(bool badFirst)
{
DataValueSnapshot?[] inputs = badFirst
? [Snap(null, BadNoCommunication), Snap(2, Uncertain)]
: [Snap(2, Uncertain), Snap(null, BadNoCommunication)];
InputQuality.WorstOf(inputs).ShouldBe(BadNoCommunication);
}
/// <summary>
/// A null entry means "this dependency has never been pushed", which is
/// <c>BadWaitingForInitialData</c> — the same code the materialiser stamps on a fresh node.
/// Reporting Good for it would be the exact laundering this helper exists to stop.
/// </summary>
[Fact]
public void WorstOf_a_null_entry_is_BadWaitingForInitialData()
{
InputQuality.WorstOf([Snap(1, Good), null]).ShouldBe(BadWaitingForInitialData);
}
/// <summary>
/// Among equal severity the FIRST seen wins, so a re-evaluation over the same inputs reports
/// the same sub-code rather than oscillating between two equally-bad codes.
/// </summary>
[Fact]
public void WorstOf_equal_severity_keeps_the_first_seen()
{
InputQuality.WorstOf([Snap(null, BadNoCommunication), Snap(null, BadInternalError)])
.ShouldBe(BadNoCommunication);
InputQuality.WorstOf([Snap(null, BadInternalError), Snap(null, BadNoCommunication)])
.ShouldBe(BadInternalError);
}
/// <summary>Good and Uncertain are usable (a script may still inspect the value); Bad is not.</summary>
[Theory]
[InlineData(Good, true)]
[InlineData(Uncertain, true)]
[InlineData(UncertainLastUsableValue, true)]
[InlineData(BadNoCommunication, false)]
[InlineData(BadWaitingForInitialData, false)]
[InlineData(0xC0000000u, false)]
public void IsUsable_follows_Part4_severity(uint statusCode, bool expected)
{
InputQuality.IsUsable(statusCode).ShouldBe(expected);
}
}