feat(engines): IVirtualTagEvaluator snapshot overload; Roslyn read cache stops fabricating Good (02/S14)

This commit is contained in:
Joseph Doherty
2026-08-20 10:45:11 -04:00
parent 60e5b40228
commit cc9b845f99
5 changed files with 166 additions and 19 deletions
@@ -770,3 +770,13 @@ test could only pass once the production change landed.
xmldoc says so and points at the snapshot overload.
- Docs: `docs/drivers/Calculation.md` gains an "Input quality" section (the policy table + the
behaviour change for null-tolerant scripts) and the dedup bullet now says **(value, status)**.
### T15 — `IVirtualTagEvaluator` snapshot overload + Roslyn read-cache pass-through
- RED: `--filter "…RoslynVirtualTagEvaluatorTests"` ⇒ **build failure**, `CS1503: cannot convert from
'Dictionary<string, DataValueSnapshot>' to 'IReadOnlyDictionary<string, object?>'` (×3).
- GREEN: 24/24 in that suite; full solution build clean (0 errors), so **all five existing
`IVirtualTagEvaluator` fakes compile untouched** — which is the point of default-implementing the
overload. A test pins that fallback explicitly (`ValueOnlyEvaluator`).
- `RoslynVirtualTagEvaluator.BuildReadCache` is **deleted**; the snapshots are the read cache. The
value-only overload now projects up into the snapshot one, so there is a single evaluation body.
@@ -110,7 +110,7 @@
{
"id": "T15",
"subject": "IVirtualTagEvaluator snapshot overload (default-implemented) + RoslynVirtualTagEvaluator.BuildReadCache pass-through (02/S14)",
"status": "pending",
"status": "done",
"blockedBy": []
},
{
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
/// <summary>
@@ -18,6 +20,34 @@ public interface IVirtualTagEvaluator
/// <param name="dependencies">Read-only dictionary of variable names to values for expression evaluation.</param>
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies);
/// <summary>
/// Evaluate <paramref name="expression"/> against dependency <b>snapshots</b> — value, status
/// code and source timestamp per input — so a script's <c>ctx.GetTag(path).StatusCode</c> reports
/// what the source actually said (archreview 02/S14).
/// </summary>
/// <remarks>
/// <b>Default-implemented on purpose.</b> It projects to values and calls the older overload, so
/// every existing implementation — the Roslyn evaluator aside, that is the null evaluator and the
/// test fakes — compiles and behaves unchanged; an implementation that can do better (the Roslyn
/// one) overrides it. Making it abstract would have been a breaking change for a fix whose whole
/// point is that it changes nothing on the Good path.
/// <para>The default necessarily discards the status codes it was given, which is exactly the
/// laundering this overload exists to end — so a consumer that cares must call an implementation
/// that overrides it.</para>
/// </remarks>
/// <param name="virtualTagId">The unique identifier of the virtual tag being evaluated.</param>
/// <param name="expression">The expression string to evaluate.</param>
/// <param name="dependencies">Read-only dictionary of dependency snapshots, keyed by tag path.</param>
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
{
ArgumentNullException.ThrowIfNull(dependencies);
var values = new Dictionary<string, object?>(dependencies.Count, StringComparer.Ordinal);
foreach (var kv in dependencies) values[kv.Key] = kv.Value?.Value;
return Evaluate(virtualTagId, expression, values);
}
}
/// <summary>Result of one virtual-tag expression eval. Stash a Reason on every Failure so
@@ -59,7 +59,34 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
}
/// <inheritdoc />
/// <remarks>
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
/// evaluation-time timestamp — bare values carry nothing better. The runtime calls the snapshot
/// overload instead (archreview 02/S14); this one remains for callers that genuinely hold only
/// values.
/// </remarks>
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
{
ArgumentNullException.ThrowIfNull(dependencies);
var nowUtc = DateTime.UtcNow;
var snapshots = new Dictionary<string, DataValueSnapshot>(dependencies.Count, StringComparer.Ordinal);
foreach (var kv in dependencies)
snapshots[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, nowUtc, nowUtc);
return Evaluate(virtualTagId, expression, snapshots);
}
/// <inheritdoc />
/// <remarks>
/// Overrides the interface's default (which projects to values and loses the status codes): the
/// snapshots pass <b>through</b> as the script's read cache, so
/// <c>ctx.GetTag(path).StatusCode</c> and <c>.SourceTimestampUtc</c> are the input's own rather
/// than a fabricated <c>Good</c> stamped at evaluation time (archreview 02/S14). The virtual-tag
/// actor holds evaluation entirely while any input is Bad, so what a script observes here is Good
/// or Uncertain.
/// </remarks>
public VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
@@ -71,12 +98,14 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
// so the script returns null — Ok(null) here too.
if (PassthroughScript.TryMatch(expression, out var passthroughRef))
{
return dependencies.TryGetValue(passthroughRef, out var ptValue)
? VirtualTagEvalResult.Ok(ptValue)
// The VALUE only — the caller stamps the result with the worst input status, which for a
// single-dependency passthrough IS this input's status.
return dependencies.TryGetValue(passthroughRef, out var ptSnapshot)
? VirtualTagEvalResult.Ok(ptSnapshot?.Value)
: VirtualTagEvalResult.Ok(null);
}
var readCache = BuildReadCache(dependencies);
var readCache = dependencies;
// Per-evaluation script logger: bind both ScriptId and VirtualTagId from the virtual-tag id
// (in the live path the script id equals the virtual-tag id) so the Script-log page can
// attribute each line. EquipmentId stays unbound for now.
@@ -162,21 +191,6 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IScriptCac
/// <inheritdoc />
public void ClearCompiledScripts() => _cache.Clear();
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
// VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
// as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
// values stay null; the script can null-check via GetTag(path).Value.
var nowUtc = DateTime.UtcNow;
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var kv in deps)
{
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
}
return cache;
}
/// <summary>Disposes the evaluator and cleans up all cached script evaluators.</summary>
public void Dispose()
{
@@ -7,6 +7,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
@@ -539,6 +540,98 @@ public sealed class RoslynVirtualTagEvaluatorTests
$"{starved}/{totalEvaluations} evaluations missed the 5s budget");
}
// ── input quality reaches the script (archreview 02/S14) ────────────────────────────────
/// <summary>
/// A script reading <c>ctx.GetTag(path).StatusCode</c> sees the dependency's <b>real</b> status.
/// The read cache used to stamp every input <c>Good</c>, so a script could not detect a degraded
/// input at all — "broken input looks Good", the other door onto the same lie 02/S13 closed for
/// broken scripts.
/// </summary>
[Fact]
public void Evaluate_read_cache_carries_the_inputs_real_status_code()
{
const uint uncertain = 0x40000000u;
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
virtualTagId: "vt-quality",
expression: "return (long)ctx.GetTag(\"a\").StatusCode;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7, uncertain, stamp, stamp),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe((long)uncertain);
}
/// <summary>The dependency's own source timestamp reaches the script, not evaluation time.</summary>
[Fact]
public void Evaluate_read_cache_carries_the_inputs_real_source_timestamp()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
virtualTagId: "vt-stamp",
expression: "return ctx.GetTag(\"a\").SourceTimestampUtc;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7, 0u, stamp, DateTime.UtcNow),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(stamp);
}
/// <summary>
/// The value-only overload keeps working unchanged — it is what the interface's default
/// implementation and the five test fakes still use, which is the whole reason the new overload is
/// default-implemented rather than abstract.
/// </summary>
[Fact]
public void The_value_only_overload_still_evaluates()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var result = sut.Evaluate(
virtualTagId: "vt-values",
expression: "return (int)ctx.GetTag(\"a\").Value + 1;",
dependencies: new Dictionary<string, object?> { ["a"] = 41 });
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(42);
}
/// <summary>
/// A fake implementing ONLY the value-only member still answers the snapshot overload, through the
/// interface's default projection. Without that default, adding the overload would have been a
/// breaking change for every existing <see cref="IVirtualTagEvaluator"/> implementation.
/// </summary>
[Fact]
public void The_snapshot_overload_falls_back_to_a_value_only_implementation()
{
IVirtualTagEvaluator fake = new ValueOnlyEvaluator();
var result = fake.Evaluate("vt-fake", "ignored", new Dictionary<string, DataValueSnapshot>
{
["a"] = new(41, 0u, DateTime.UtcNow, DateTime.UtcNow),
});
result.Success.ShouldBeTrue();
result.Value.ShouldBe(41);
}
/// <summary>An evaluator predating the snapshot overload: implements the value-only member only.</summary>
private sealed class ValueOnlyEvaluator : IVirtualTagEvaluator
{
public VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.Ok(dependencies["a"]);
}
/// <summary>Reads the count of the private compiled-script cache via reflection. The cache is a
/// <c>CompiledScriptCache&lt;,&gt;</c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)