fix(calculation): read cache carries real input status + timestamps

This commit is contained in:
Joseph Doherty
2026-08-20 10:40:15 -04:00
parent 48efdac600
commit 60e5b40228
6 changed files with 163 additions and 25 deletions
@@ -547,11 +547,9 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
return;
}
// Projected to bare values for now — the evaluator's own read cache still fabricates Good, which
// the next task fixes by handing it the snapshots. Only reachable with USABLE inputs (the Bad
// hold above returned), so nothing Bad is being flattened here.
var depValues = pending.Dependencies.ToDictionary(kv => kv.Key, kv => kv.Value.Value, StringComparer.Ordinal);
var result = _evaluator.Evaluate(def.RawPath, def.ScriptSource, depValues);
// Snapshots, not bare values: the script's ctx.GetTag(path).StatusCode / .SourceTimestampUtc are
// then the input's real ones rather than a fabricated Good stamped at evaluation time (05/STAB-19).
var result = _evaluator.Evaluate(def.RawPath, def.ScriptSource, pending.Dependencies);
string? failureReason = null;
(object? Value, uint StatusCode)? toPublish;
@@ -75,7 +75,44 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
/// <remarks>
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
/// evaluation-time timestamp, because bare values carry nothing better. Prefer
/// <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>, which the
/// driver uses: it carries each input's real status and source timestamp, so
/// <c>ctx.GetTag(path).StatusCode</c> is honest (archreview 05/STAB-19). This overload remains for
/// callers that genuinely hold only values — chiefly tests.
/// </remarks>
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, 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(calcTagId, scriptSource, snapshots);
}
/// <summary>
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
/// the dependency <b>snapshots</b> in <paramref name="dependencies"/> — value, status code and
/// source timestamp per input. Never throws; script faults surface as
/// <see cref="VirtualTagEvalResult.Failure"/>.
/// </summary>
/// <remarks>
/// The snapshots pass <b>through</b> to the script's read cache rather than being re-wrapped as
/// Good (archreview 05/STAB-19). The old wrapping meant a script that inspected
/// <c>ctx.GetTag(path).StatusCode</c> was told Good for every input, including one whose driver had
/// stopped communicating — so a quality-aware script could not be written at all. The driver holds
/// evaluation entirely while any input is Bad, so what a script sees here is Good or Uncertain.
/// </remarks>
/// <param name="calcTagId">Identity of the calc tag (bound to the script log).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
/// <returns>The evaluation outcome.</returns>
public VirtualTagEvalResult Evaluate(
string calcTagId, string scriptSource, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
@@ -86,12 +123,15 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
// returns null — Ok(null) here too. Near-misses fall through to the compiled path.
if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
{
return dependencies.TryGetValue(passthroughRef, out var ptValue)
? VirtualTagEvalResult.Ok(ptValue)
// The VALUE only — the caller stamps the output with the worst input status, which for a
// single-dependency passthrough IS this input's status. Returning it here as well would put
// the same decision in two places.
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 CalcTagId (via the VirtualTagId slot, which
// the Script-log page attributes on) from the calc-tag id so each line is attributable.
var scriptLog = _scriptRoot
@@ -173,20 +213,6 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
/// <inheritdoc />
public void ClearCompiledScripts() => _cache.Clear();
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
// VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
// so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
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, draining + disposing every cached compile's collectible ALC.</summary>
public void Dispose()
{