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
@@ -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()
{