diff --git a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
index 595d60b5..8b4e615f 100644
--- a/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
+++ b/archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json
@@ -63,7 +63,7 @@
{
"id": "R2-03-T9",
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
- "status": "pending",
+ "status": "completed",
"blockedBy": [
"R2-03-T3"
]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
index 1741028f..e71a290a 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs
@@ -345,24 +345,52 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
mux.LastSender.ShouldNotBe(firstChild);
}
- /// Wiring guard (arch-review 02/U3, theme #1): every ApplyVirtualTags calls
- /// on the evaluator at the apply boundary.
- /// This is the load-bearing wire — the production evaluator roots one collectible ALC per compiled
- /// script, and without this per-generation clear they accrete across deploys. A pure unit test of
- /// the evaluator can't catch a missing call here; this asserts the host actor actually makes it.
+ /// Wiring guard (arch-review 02/U3 + 02/P7, theme #1): the host actor calls
+ /// at the apply boundary — but, per P7, ONLY
+ /// when the deployed expression set actually changed. The clear stays the load-bearing wire (the
+ /// production evaluator roots one collectible ALC per compiled script), yet a byte-identical
+ /// redeploy — or a rename / add / remove that leaves the expression set unchanged — no longer forces
+ /// a full recompilation. This pins that the host makes the call, and gates it correctly.
+ ///
+ /// The final ClearCount is read at a deterministic sync point: after all applies are
+ /// queued we drive one EvaluationResult through the actor and wait for the bridged publish — the
+ /// FIFO mailbox guarantees every prior apply is processed by then, so the counter is final (a
+ /// polling AwaitAssert on a monotonic counter would pass transiently as it climbs, hiding the
+ /// unconditional-clear bug).
[Fact]
- public void ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation()
+ public void ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes()
{
var publish = CreateTestProbe();
var evaluator = new CacheOwningStubEvaluator();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, evaluator));
- host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
- AwaitAssert(() => evaluator.ClearCount.ShouldBe(1));
+ // 1) First generation: previous set {} → {E1} differs → clears.
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
+ new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E1", "a") }));
+ // 2) Byte-identical redeploy: {E1} → {E1} → NO clear (the P7 win).
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
+ new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E1", "a") }));
+ // 3) Edited expression: {E1} → {E2} differs → clears.
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
+ new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E2", "a") }));
+ // 4) Add a second vtag SHARING the same expression E2: {E2} → {E2} → NO clear.
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[]
+ {
+ PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E2", "a"),
+ PlanWithRefs("vt-2", "eq-1", "torque", "E2", "a"),
+ }));
+ // 5) Remove that second vtag — expression E2 still deployed by vt-1: {E2} → {E2} → NO clear.
+ host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
+ new[] { PlanWithRefs("vt-1", "eq-1", "speed-rpm", "E2", "a") }));
- // A second apply generation clears again — proves it's per-generation, not one-shot.
- host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
- AwaitAssert(() => evaluator.ClearCount.ShouldBe(2));
+ // Sync point: a result for the still-mapped vt-1 bridges to a publish; when it lands, every
+ // apply above has been processed.
+ host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 1.0, DateTime.UtcNow, CorrelationId.NewId()));
+ publish.ExpectMsg();
+
+ // Exactly two clears: the first generation and the E1→E2 edit. The identical redeploy and the
+ // shared-expression add/remove did not clear.
+ evaluator.ClearCount.ShouldBe(2);
}
///