Phase 7 Stream B — Core.VirtualTags engine + dep graph + timer + source #180

Merged
dohertj2 merged 1 commits from phase-7-stream-b-virtual-tag-engine into v2 2026-04-20 17:05:15 -04:00
Owner

Ships the evaluation engine that consumes compiled scripts from Stream A, subscribes to upstream driver tags, runs on change + on timer, cascades evaluations through dependent virtual tags in topological order, and emits changes through a driver-capability-shaped adapter the DriverNodeManager can dispatch to per ADR-002.

New project Core.VirtualTags

  • DependencyGraph — Kahn topological sort + iterative Tarjan SCC cycle detection (both non-recursive, handles 10k deep chains)
  • VirtualTagDefinition — operator config row (Path / DataType / ScriptSource / ChangeTriggered / TimerInterval / Historize)
  • ITagUpstreamSource / IHistoryWriter — abstractions for driver-tag reads + history sink
  • VirtualTagContext — per-evaluation ScriptContext with cached reads + write-callback + injectable clock
  • VirtualTagEngine — orchestrator: compiles every script, builds dep graph, checks cycles, subscribes to upstream, cascades evaluations in topological order through a SemaphoreSlim, per-tag error isolation, Historize routing
  • TimerTriggerScheduler — groups tags by interval into shared Timers
  • VirtualTagSourceIReadable + ISubscribable adapter for DriverNodeManager dispatch per ADR-002 (IWritable deliberately omitted — OPC UA writes to virtual tags rejected upstream)

Tests — 36/36

  • DependencyGraphTests (12): empty / single / topo ordering / self-loop / 2-node + 3-node cycles / multiple disjoint cycles all reported / throws on cycle / DirectDependents / TransitiveDependentsInOrder / re-add cleans up / leaf implicit / 10k deep no stack overflow
  • VirtualTagEngineTests (13): simple read+coerce / 2-level cascade / cycle rejected at Load / compile errors aggregated / runtime exception isolates to owning tag / timeout isolates / subscribers receive changes / Historize on+off / ChangeTriggered=false / reload replaces+cleans subscriptions / Dispose releases / SetVirtualTag fires observers / type coercion 3.7→4
  • VirtualTagSourceTests (6): ReadAsync cache hit / unknown returns Bad / SubscribeAsync initial-data per spec / cascade-emits-on-upstream / Unsubscribe stops events / null rejection
  • TimerTriggerSchedulerTests (4): periodic ticks / no-timer tags not scheduled / multiple intervals grouped / disposed rejects re-Start

Two bugs fixed during implementation

  1. Monitor.Enter/Exit can't be held across await — swapped to SemaphoreSlim.WaitAsync
  2. Kahn edge-direction was inverted — was incrementing inDegree[dep] instead of inDegree[nodeId], producing false cycle detection on valid DAGs

Totals

Full Phase 7 tests: 99 green (63 Scripting + 36 VirtualTags). Streams C (scripted alarms) and G (address-space integration) plug the engine + source into the live OPC UA dispatch path.

Ships the evaluation engine that consumes compiled scripts from Stream A, subscribes to upstream driver tags, runs on change + on timer, cascades evaluations through dependent virtual tags in topological order, and emits changes through a driver-capability-shaped adapter the DriverNodeManager can dispatch to per ADR-002. ## New project `Core.VirtualTags` - **`DependencyGraph`** — Kahn topological sort + iterative Tarjan SCC cycle detection (both non-recursive, handles 10k deep chains) - **`VirtualTagDefinition`** — operator config row (Path / DataType / ScriptSource / ChangeTriggered / TimerInterval / Historize) - **`ITagUpstreamSource`** / **`IHistoryWriter`** — abstractions for driver-tag reads + history sink - **`VirtualTagContext`** — per-evaluation `ScriptContext` with cached reads + write-callback + injectable clock - **`VirtualTagEngine`** — orchestrator: compiles every script, builds dep graph, checks cycles, subscribes to upstream, cascades evaluations in topological order through a `SemaphoreSlim`, per-tag error isolation, Historize routing - **`TimerTriggerScheduler`** — groups tags by interval into shared `Timer`s - **`VirtualTagSource`** — `IReadable + ISubscribable` adapter for `DriverNodeManager` dispatch per ADR-002 (`IWritable` deliberately omitted — OPC UA writes to virtual tags rejected upstream) ## Tests — 36/36 - `DependencyGraphTests` (12): empty / single / topo ordering / self-loop / 2-node + 3-node cycles / multiple disjoint cycles all reported / throws on cycle / DirectDependents / TransitiveDependentsInOrder / re-add cleans up / leaf implicit / 10k deep no stack overflow - `VirtualTagEngineTests` (13): simple read+coerce / 2-level cascade / cycle rejected at Load / compile errors aggregated / runtime exception isolates to owning tag / timeout isolates / subscribers receive changes / Historize on+off / ChangeTriggered=false / reload replaces+cleans subscriptions / Dispose releases / SetVirtualTag fires observers / type coercion 3.7→4 - `VirtualTagSourceTests` (6): ReadAsync cache hit / unknown returns Bad / SubscribeAsync initial-data per spec / cascade-emits-on-upstream / Unsubscribe stops events / null rejection - `TimerTriggerSchedulerTests` (4): periodic ticks / no-timer tags not scheduled / multiple intervals grouped / disposed rejects re-Start ## Two bugs fixed during implementation 1. `Monitor.Enter/Exit` can't be held across `await` — swapped to `SemaphoreSlim.WaitAsync` 2. Kahn edge-direction was inverted — was incrementing `inDegree[dep]` instead of `inDegree[nodeId]`, producing false cycle detection on valid DAGs ## Totals Full Phase 7 tests: **99 green** (63 Scripting + 36 VirtualTags). Streams C (scripted alarms) and G (address-space integration) plug the engine + source into the live OPC UA dispatch path.
dohertj2 added 1 commit 2026-04-20 17:05:04 -04:00
Ships the evaluation engine that consumes compiled scripts from Stream A, subscribes to upstream driver tags, runs on change + on timer, cascades evaluations through dependent virtual tags in topological order, and emits changes through a driver-capability-shaped adapter the DriverNodeManager can dispatch to per ADR-002.

DependencyGraph owns the directed dep-graph where nodes are tag paths (driver tags implicit leaves, virtual tags registered internal nodes) and edges run from a virtual tag to each tag it reads. Kahn algorithm produces the topological sort. Tarjan iterative SCC detects every cycle in one pass so publish-time rejection surfaces all offending cycles together. Both iterative so 10k-deep chains do not StackOverflow. Re-adding a node overwrites prior dependency set cleanly (supports config-publish reloads).

VirtualTagDefinition is the operator-authored config row (Path, DataType, ScriptSource, ChangeTriggered, TimerInterval, Historize). Stream E config DB materializes these on publish.

ITagUpstreamSource is the abstraction the engine pulls driver tag values from. Stream G bridges this to IReadable + ISubscribable on live drivers; tests use FakeUpstream that tracks subscription count for leak-test assertions.

IHistoryWriter is the per-tag Historize sink. NullHistoryWriter default when caller does not pass one.

VirtualTagContext is the per-evaluation ScriptContext. Reads from engine last-known-value cache, writes route through SetVirtualTag callback so cross-tag side effects participate in change cascades. Injectable Now clock for deterministic tests.

VirtualTagEngine orchestrates. Load compiles every script via ScriptSandbox, builds the dep graph via DependencyExtractor, checks for cycles, reports every compile failure in one error, subscribes to each referenced upstream path, seeds the value cache. EvaluateAllAsync runs topological order. EvaluateOneAsync is timer path. Read returns cached value. Subscribe registers observer. OnUpstreamChange updates cache, fans out, schedules transitive dependents (change-driven=false tags skipped). EvaluateInternalAsync holds a SemaphoreSlim so cascades do not interleave. Script exceptions and timeouts map per-tag to BadInternalError. Coercion from script double to config Int32 uses Convert.ToInt32.

TimerTriggerScheduler groups tags by interval into shared Timers. Tags without TimerInterval not scheduled.

VirtualTagSource implements IReadable + ISubscribable per ADR-002. ReadAsync returns cache. SubscribeAsync fires initial-data callback per OPC UA convention. IWritable deliberately not implemented — OPC UA writes to virtual tags rejected in DriverNodeManager per Phase 7 decision 6.

36 unit tests across 4 files: DependencyGraphTests 12, VirtualTagEngineTests 13, VirtualTagSourceTests 6, TimerTriggerSchedulerTests 4. Coverage includes cycle detection (self-loop, 2-node, 3-node, multiple disjoint), 2-level change cascade, per-tag error isolation (one tag throws, others keep working), timeout isolation, Historize toggle, ChangeTriggered=false ignore, reload cleans subscriptions, Dispose releases resources, SetVirtualTag fires observers, type coercion, 10k deep graph no stack overflow, initial-data callback, Unsubscribe stops events.

Fixed two bugs during implementation. Monitor.Enter/Exit cannot be held across await (Monitor ownership is thread-local and lost across suspension) — switched to SemaphoreSlim. Kahn edge-direction was inverted — for dependency ordering (X depends on Y means Y comes before X) in-degree should be count of a node own deps, not count of nodes pointing to it; was incrementing inDegree[dep] instead of inDegree[nodeId], causing false cycle detection on valid DAGs.

Full Phase 7 test count after Stream B: 99 green (63 Scripting + 36 VirtualTags). Streams C and G will plug engine + source into live OPC UA dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dohertj2 merged commit 2a8bcc8f60 into v2 2026-04-20 17:05:15 -04:00
dohertj2 referenced this issue from a commit 2026-04-30 08:21:26 -04:00
ab_server integration fixture — per-family profiles + documented CI-fetch contract. Closes task #180 (AB CIP follow-up — ab_server CI fixture). Replaces the prior hardcoded single-family fixture with a parametric AbServerProfile abstraction covering ControlLogix / CompactLogix / Micro800 / GuardLogix. Prebuilt-Windows-binary fetch is documented as a CI YAML step rather than fabricated C#-side, because SHA-pinned binary distribution is a CI workflow concern (libplctag owns releases, we pin a version + verify hash) not a test-framework concern. New AbServerProfile record + KnownProfiles static class at tests/.../AbServerProfile.cs. Four profiles: ControlLogix (widest coverage — DINT/REAL/BOOL/SINT/STRING atomic + DINT[16] array so the driver's @tags Symbol-Object decoder + array-bound path both get end-to-end coverage), CompactLogix (atomic subset — driver-side ConnectionSize quirk from PR 10 still applies since ab_server doesn't enforce the narrower limit), Micro800 (ab_server has no dedicated --plc micro800 mode — falls back to controllogix while driver-side path enforces empty routing + unconnected-only per PR 11; real Micro800 coverage requires a 2080 lab rig), GuardLogix (ab_server has no safety subsystem — profile emulates the _S-suffixed naming contract the driver's safety-ViewOnly classification reads in PR 12; real safety-lock behavior requires a 1756-L8xS physical rig). Each profile composes --plc + --tag args via BuildCliArgs(port) — pure string formatter so the composition logic is unit-testable without launching the simulator. AbServerFixture gains a ctor overload taking AbServerProfile + port (defaults back to ControlLogix on parameterless ctor so existing test suites keep compiling). Fixture's InitializeAsync hands the profile's CLI args to ProcessStartInfo.Arguments. New AbServerTheoryAttribute mirrors AbServerFactAttribute but extends TheoryAttribute so a single test can MemberData over KnownProfiles.All + cover all four families. AbCipReadSmokeTests converted from single-fact to theory parametrized over KnownProfiles.All — one row per family reads TestDINT + asserts Good status + Healthy driver state. Fixture lifecycle is explicit try/finally rather than await using because IAsyncLifetime.DisposeAsync returns ValueTask + xUnit's concrete IAsyncDisposable shim depends on xunit version; explicit beats implicit here. Eight new unit tests in AbServerProfileTests.cs (runs without the simulator so CI green even when the binary is absent): BuildCliArgs composes port + plc + tag flags in the documented order; empty seed-tag list still emits port + plc; SeedTag.ToCliSpec handles both 2-segment scalar + 3-segment array; KnownProfiles.ForFamily returns expected --plc arg for every family (verifies Micro800 + GuardLogix both fall back to controllogix); KnownProfiles.All covers every AbCipPlcFamily enum value (regression guard — adding a new family without a profile fails this test); ControlLogix seeds every atomic type the driver supports; GuardLogix seeds at least one _S-suffixed safety tag. Integration tests still skip cleanly when ab_server isn't on PATH. 11/11 unit tests passing in this project (8 new + 3 prior). Full Admin solution builds 0 errors. docs/v2/test-data-sources.md gets a new "CI fixture" subsection under §2.Gotchas with the exact GitHub Actions YAML step — fetch the pinned libplctag release, SHA256-verify against a pinned hash recorded in the repo's CI lockfile (drift = fail closed), extract, append to PATH. The C# harness stays PATH-driven so dev-box installs (cmake + make from source) work identically to CI.
dohertj2 referenced this issue from a commit 2026-04-30 08:21:26 -04:00
Pin libplctag ab_server to v2.6.16 — real release tag + SHA256 hashes for all three Windows arches. Closes the "pick a current version + pin" deferral left by the #180 PR docs stub. Verified the release lands ab_server.exe inside libplctag_2.6.16_windows_<arch>_tools.zip alongside plctag.dll + list_tags_* helpers by downloading each tools zip + unzip -l'ing to confirm ab_server.exe is present at 331264 bytes. New ci/ab-server.lock.json is the single source of truth — one file the CI YAML reads via ConvertFrom-Json instead of duplicating the hash across the workflow + the docs. Structure: repo (libplctag/libplctag) + tag (v2.6.16) + published date (2026-03-29) + assets keyed by platform (windows-x64 / windows-x86 / windows-arm64) each carrying filename + sha256. docs/v2/test-data-sources.md §2.CI updated — replaces the prior placeholder (ver = '<pinned libplctag release tag>', expected = '<pinned sha256>') with the real v2.6.16 + 9b78a3de... hashes pinned table, and replaces the hardcoded URL with a lockfile-driven pwsh step that picks windows-x64 by default but swaps to x86/arm64 by changing one line for non-x64 CI runners. Hash-mismatch path throws with both the expected + actual values so on the first drift the CI log tells the maintainer exactly what to update in the lockfile. Two verification notes from the release fetch: (1) libplctag v2.6.16 tools zips ship ab_server.exe + plctag.dll together — tests don't need a separate libplctag NuGet download for the integration path, the extracted tools dir covers both the simulator + the driver's native dependency; (2) the three Windows arches all carry ab_server.exe, so ARM64 Windows GitHub runners (when they arrive) can run the integration suite without changes beyond swapping the asset key. No code changes in this PR — purely docs + the new lockfile. Admin tests + Core tests unchanged + passing per the prior commit.
Sign in to join this conversation.