From e16553feaa892a63b2f2bd71af51b8576299ff78 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:12:23 +0700 Subject: [PATCH 01/12] Add unified per-stream SV observation manager --- .../Profiles/SvStreamObservationManager.cs | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs new file mode 100644 index 0000000..ca837fe --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -0,0 +1,251 @@ +using System.Collections.Concurrent; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvObservationInputKind +{ + Unknown, + LiveCapture, + PcapReplay +} + +public sealed record SvObservedStreamKey +{ + public string SourceMac { get; init; } = string.Empty; + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public ushort AppId { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + + public string Id + { + get + { + var vlan = VlanId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"; + return $"SV|{AppId:X4}|{SourceMac}|{DestinationMac}|{vlan}|{SvId}|{DataSetReference}"; + } + } + + public static SvObservedStreamKey FromFrame(SampledValuesFrame frame) + { + ArgumentNullException.ThrowIfNull(frame); + var first = frame.Pdu.Asdus.FirstOrDefault() + ?? throw new ArgumentException("An observed SV frame requires at least one ASDU.", nameof(frame)); + + return new SvObservedStreamKey + { + SourceMac = frame.Source.ToString(), + DestinationMac = frame.Destination.ToString(), + VlanId = frame.Vlan?.VlanId, + AppId = frame.AppId, + SvId = first.SvId, + DataSetReference = first.DataSetReference + }; + } +} + +public sealed record SvStreamObservationSnapshot +{ + public SvObservedStreamKey Key { get; init; } = new(); + public SvObservedStreamFacts Facts { get; init; } = new(); + public IReadOnlyList InputKinds { get; init; } + = Array.Empty(); + public SvObservationInputKind LastInputKind { get; init; } + public bool IsBoundToScl { get; init; } + public string ControlBlockReference { get; init; } = string.Empty; + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public sealed class SvStreamObservationManager +{ + private sealed class StreamState + { + private readonly object _gate = new(); + private readonly HashSet _inputKinds = []; + private readonly Queue _diagnostics = new(); + + public StreamState(int maximumObservations, TimeSpan maximumAge) + { + Accumulator = new SvObservationAccumulator(maximumObservations, maximumAge); + } + + public SvObservationAccumulator Accumulator { get; } + public SvObservationInputKind LastInputKind { get; private set; } + public bool IsBoundToScl { get; private set; } + public string ControlBlockReference { get; private set; } = string.Empty; + + public void Add( + SvFrameObservation observation, + SvObservationInputKind inputKind, + SampledValuesPublisherProfile? profile, + IEnumerable diagnostics) + { + Accumulator.Add(observation); + lock (_gate) + { + LastInputKind = inputKind; + _inputKinds.Add(inputKind); + if (profile is not null) + { + IsBoundToScl = true; + ControlBlockReference = profile.Stream.ControlBlockReference; + } + + foreach (var diagnostic in diagnostics.Where(item => !string.IsNullOrWhiteSpace(item))) + { + if (_diagnostics.Contains(diagnostic, StringComparer.Ordinal)) + continue; + + _diagnostics.Enqueue(diagnostic); + while (_diagnostics.Count > 16) + _diagnostics.Dequeue(); + } + } + } + + public SvStreamObservationSnapshot Snapshot(SvObservedStreamKey key) + { + var facts = Accumulator.BuildFacts(); + lock (_gate) + { + return new SvStreamObservationSnapshot + { + Key = key, + Facts = facts, + InputKinds = _inputKinds.OrderBy(item => item).ToArray(), + LastInputKind = LastInputKind, + IsBoundToScl = IsBoundToScl, + ControlBlockReference = ControlBlockReference, + Diagnostics = facts.Diagnostics.Concat(_diagnostics).Distinct(StringComparer.Ordinal).ToArray() + }; + } + } + } + + private readonly ConcurrentDictionary _streams = new(); + private readonly int _maximumObservations; + private readonly TimeSpan _maximumAge; + + public SvStreamObservationManager( + int maximumObservations = SvObservationAccumulator.DefaultMaximumObservations, + TimeSpan? maximumAge = null) + { + if (maximumObservations < 2) + throw new ArgumentOutOfRangeException(nameof(maximumObservations)); + + _maximumAge = maximumAge ?? SvObservationAccumulator.DefaultMaximumAge; + if (_maximumAge <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(maximumAge)); + + _maximumObservations = maximumObservations; + } + + public int Count => _streams.Count; + + public bool TryObserve( + DateTimeOffset timestamp, + SampledValuesFrame frame, + SvObservationInputKind inputKind, + SampledValuesPublisherProfile? profile, + out SvStreamObservationSnapshot snapshot, + double? nominalFrequencyHz = null) + { + ArgumentNullException.ThrowIfNull(frame); + snapshot = new SvStreamObservationSnapshot(); + + var asdus = frame.Pdu.Asdus; + var first = asdus.FirstOrDefault(); + if (first is null || first.SamplePayload.Length <= 0) + return false; + + var key = SvObservedStreamKey.FromFrame(frame); + var diagnostics = ValidateFrameConsistency(asdus); + var signature = profile?.Entries.Select(ToSignature).ToArray() + ?? Array.Empty(); + var payloadLength = asdus.Select(item => item.SamplePayload.Length).Distinct().SingleOrDefault(); + if (payloadLength <= 0) + payloadLength = first.SamplePayload.Length; + + var observation = new SvFrameObservation + { + Timestamp = timestamp, + EtherType = 0x88BA, + AppId = frame.AppId, + DestinationMac = frame.Destination.ToString(), + VlanId = frame.Vlan?.VlanId, + VlanPriority = frame.Vlan?.PriorityCodePoint, + SvId = first.SvId, + DataSetReference = first.DataSetReference, + ConfigurationRevision = first.ConfigurationRevision, + PayloadBytesPerAsdu = payloadLength, + SampleCounts = asdus.Select(item => item.SampleCount).ToArray(), + DeclaredSampleRate = StableNullable(asdus.Select(item => item.SampleRate)), + DeclaredSampleMode = StableNullable(asdus.Select(item => item.SampleMode)), + NominalFrequencyHz = nominalFrequencyHz, + DataSetSignature = signature + }; + + var state = _streams.GetOrAdd( + key, + _ => new StreamState(_maximumObservations, _maximumAge)); + state.Add(observation, inputKind, profile, diagnostics); + snapshot = state.Snapshot(key); + return true; + } + + public bool TryGetSnapshot(SvObservedStreamKey key, out SvStreamObservationSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(key); + if (_streams.TryGetValue(key, out var state)) + { + snapshot = state.Snapshot(key); + return true; + } + + snapshot = new SvStreamObservationSnapshot(); + return false; + } + + public IReadOnlyList SnapshotAll() + => _streams.Select(pair => pair.Value.Snapshot(pair.Key)) + .OrderBy(item => item.Key.AppId) + .ThenBy(item => item.Key.SvId, StringComparer.Ordinal) + .ThenBy(item => item.Key.DataSetReference, StringComparer.Ordinal) + .ToArray(); + + public void Clear() => _streams.Clear(); + + private static IReadOnlyList ValidateFrameConsistency(IReadOnlyList asdus) + { + var diagnostics = new List(); + var first = asdus[0]; + + if (asdus.Any(item => !string.Equals(item.SvId, first.SvId, StringComparison.Ordinal))) + diagnostics.Add("ASDUs inside one Ethernet frame expose different svID values."); + if (asdus.Any(item => !string.Equals(item.DataSetReference, first.DataSetReference, StringComparison.Ordinal))) + diagnostics.Add("ASDUs inside one Ethernet frame expose different dataset references."); + if (asdus.Any(item => item.ConfigurationRevision != first.ConfigurationRevision)) + diagnostics.Add("ASDUs inside one Ethernet frame expose different confRev values."); + if (asdus.Select(item => item.SamplePayload.Length).Distinct().Count() > 1) + diagnostics.Add("ASDUs inside one Ethernet frame expose different payload lengths."); + + return diagnostics; + } + + private static SvDatasetElementSignature ToSignature(SclDataSetEntry entry) + => new() + { + BType = entry.BType, + Cdc = entry.Cdc, + IsQuality = entry.IsQuality, + IsTimestamp = entry.IsTimestamp + }; + + private static ushort? StableNullable(IEnumerable values) + { + var distinct = values.Distinct().ToArray(); + return distinct.Length == 1 ? distinct[0] : null; + } +} From d35ad7846c269ac624c80978721419b38f299439 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:14:14 +0700 Subject: [PATCH 02/12] Add unified live and PCAP observation pipeline tests --- .../SvStreamObservationManagerTests.cs | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvStreamObservationManagerTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvStreamObservationManagerTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvStreamObservationManagerTests.cs new file mode 100644 index 0000000..2154744 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvStreamObservationManagerTests.cs @@ -0,0 +1,208 @@ +using AR.Iec61850.Ethernet; +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Profiles; +using AR.Iec61850.Scl; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Profiles; + +public sealed class SvStreamObservationManagerTests +{ + [Fact] + public void LiveAndPcapInputsShareOneStableStreamWindow() + { + var manager = new SvStreamObservationManager(); + var live = CreateFrame(sampleCount: 10); + var replay = CreateFrame(sampleCount: 11); + + Assert.True(manager.TryObserve( + DateTimeOffset.UnixEpoch, + live, + SvObservationInputKind.LiveCapture, + profile: null, + out _)); + Assert.True(manager.TryObserve( + DateTimeOffset.UnixEpoch.AddMilliseconds(1), + replay, + SvObservationInputKind.PcapReplay, + profile: null, + out var snapshot)); + + Assert.Equal(1, manager.Count); + Assert.Equal(2, snapshot.Facts.ObservationCount); + Assert.Equal( + new[] { SvObservationInputKind.LiveCapture, SvObservationInputKind.PcapReplay }, + snapshot.InputKinds); + Assert.Equal(SvObservationInputKind.PcapReplay, snapshot.LastInputKind); + Assert.Equal("SV|4001|02:00:00:00:00:01|01:0C:CD:04:00:01|100|MU01SV01|MU01MUnn/LLN0$PhsMeas", snapshot.Key.Id); + } + + [Fact] + public void ConfigurationRevisionChangesRemainInOneStreamAndBecomeUnstableFacts() + { + var manager = new SvStreamObservationManager(); + + manager.TryObserve( + DateTimeOffset.UnixEpoch, + CreateFrame(sampleCount: 20, configurationRevision: 1), + SvObservationInputKind.LiveCapture, + profile: null, + out _); + manager.TryObserve( + DateTimeOffset.UnixEpoch.AddMilliseconds(1), + CreateFrame(sampleCount: 21, configurationRevision: 2), + SvObservationInputKind.LiveCapture, + profile: null, + out var snapshot); + + Assert.Equal(1, manager.Count); + Assert.Null(snapshot.Facts.ConfigurationRevision); + Assert.Contains(snapshot.Diagnostics, item => item.Contains("confRev changed", StringComparison.Ordinal)); + } + + [Fact] + public void DatasetReferenceIsPartOfTheStreamIdentity() + { + var manager = new SvStreamObservationManager(); + + manager.TryObserve( + DateTimeOffset.UnixEpoch, + CreateFrame(sampleCount: 1, dataSetReference: "MU01MUnn/LLN0$PhsMeas"), + SvObservationInputKind.PcapReplay, + profile: null, + out _); + manager.TryObserve( + DateTimeOffset.UnixEpoch.AddMilliseconds(1), + CreateFrame(sampleCount: 1, dataSetReference: "MU01MUnn/LLN0$Protection"), + SvObservationInputKind.PcapReplay, + profile: null, + out _); + + Assert.Equal(2, manager.Count); + Assert.Equal(2, manager.SnapshotAll().Count); + } + + [Fact] + public void SclBindingAddsDatasetSignatureAndProvenance() + { + var manager = new SvStreamObservationManager(); + var profile = CreateProfile(); + + manager.TryObserve( + DateTimeOffset.UnixEpoch, + CreateFrame(sampleCount: 30), + SvObservationInputKind.LiveCapture, + profile, + out var snapshot, + nominalFrequencyHz: 50); + + Assert.True(snapshot.IsBoundToScl); + Assert.Equal("MU01MUnn/LLN0$SV$MSVCB01", snapshot.ControlBlockReference); + Assert.Equal(2, snapshot.Facts.DataSetSignature.Count); + Assert.Equal( + SvFactSource.SclDerived, + snapshot.Facts.Provenance[nameof(SvObservedStreamFacts.DataSetSignature)]); + Assert.Equal( + SvFactSource.TrustedContext, + snapshot.Facts.Provenance[nameof(SvObservedStreamFacts.NominalFrequencyHz)]); + } + + [Fact] + public void EmptyAsduFrameIsRejectedWithoutCreatingAStream() + { + var manager = new SvStreamObservationManager(); + var frame = new SampledValuesFrame + { + Source = MacAddress.Parse("02:00:00:00:00:01"), + Destination = MacAddress.Parse("01:0C:CD:04:00:01"), + AppId = 0x4001, + Pdu = new SampledValuesPdu() + }; + + var accepted = manager.TryObserve( + DateTimeOffset.UnixEpoch, + frame, + SvObservationInputKind.LiveCapture, + profile: null, + out _); + + Assert.False(accepted); + Assert.Equal(0, manager.Count); + } + + [Fact] + public void ClearRemovesAllInputWindows() + { + var manager = new SvStreamObservationManager(); + manager.TryObserve( + DateTimeOffset.UnixEpoch, + CreateFrame(sampleCount: 1), + SvObservationInputKind.LiveCapture, + profile: null, + out _); + + manager.Clear(); + + Assert.Equal(0, manager.Count); + Assert.Empty(manager.SnapshotAll()); + } + + private static SampledValuesFrame CreateFrame( + ushort sampleCount, + uint configurationRevision = 7, + string dataSetReference = "MU01MUnn/LLN0$PhsMeas") + => new() + { + Source = MacAddress.Parse("02:00:00:00:00:01"), + Destination = MacAddress.Parse("01:0C:CD:04:00:01"), + Vlan = new VlanTag(4, 100), + AppId = 0x4001, + Pdu = new SampledValuesPdu + { + Asdus = + [ + new SampledValueAsdu + { + SvId = "MU01SV01", + DataSetReference = dataSetReference, + SampleCount = sampleCount, + ConfigurationRevision = configurationRevision, + SampleRate = 80, + SampleMode = 0, + SamplePayload = new byte[8] + } + ] + } + }; + + private static SampledValuesPublisherProfile CreateProfile() + => SampledValuesPublisherProfile.Create(new SclSampledValuesStream + { + Kind = "SV", + IedName = "MU01", + LdInst = "MUnn", + ControlName = "MSVCB01", + ControlBlockReference = "MU01MUnn/LLN0$SV$MSVCB01", + SvId = "MU01SV01", + DataSetName = "PhsMeas", + DataSetReference = "MU01MUnn/LLN0$PhsMeas", + ConfigurationRevision = 7, + SampleRate = 80, + SampleMode = "SmpPerPeriod", + NoAsdu = 1, + Address = new SclStreamAddress + { + AppIdText = "0x4001", + AppId = 0x4001, + DestinationMacText = "01:0C:CD:04:00:01", + DestinationMac = MacAddress.Parse("01:0C:CD:04:00:01"), + VlanId = 100, + VlanPriority = 4 + }, + Entries = + [ + new SclDataSetEntry { Index = 1, SignalReference = "TCTR1.Amp.instMag.i", BType = "INT32", Cdc = "SAV" }, + new SclDataSetEntry { Index = 2, SignalReference = "TCTR1.Amp.q", BType = "Quality", Cdc = "SAV", IsQuality = true } + ] + }); +} From d466d301aee032815e1daa2e5ee4e305422ec99a Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:15:20 +0700 Subject: [PATCH 03/12] Add temporary P3B integration workflow --- .github/workflows/p3b-integrate.yml | 113 ++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/p3b-integrate.yml diff --git a/.github/workflows/p3b-integrate.yml b/.github/workflows/p3b-integrate.yml new file mode 100644 index 0000000..75f7ff9 --- /dev/null +++ b/.github/workflows/p3b-integrate.yml @@ -0,0 +1,113 @@ +name: Temporary P3B Integration + +on: + push: + branches: [ agent/p3b-observation-pipeline ] + +permissions: + contents: write + +jobs: + integrate: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + steps: + - name: Checkout branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: agent/p3b-observation-pipeline + fetch-depth: 0 + + - name: Wire live and PCAP paths into the shared observation manager + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text(encoding='utf-8') + if old not in text: + raise SystemExit(f'Expected text not found in {path}: {old[:120]!r}') + file.write_text(text.replace(old, new, 1), encoding='utf-8', newline='\n') + + manager = 'src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs' + replace_once( + manager, + ' var payloadLength = asdus.Select(item => item.SamplePayload.Length).Distinct().SingleOrDefault();\n if (payloadLength <= 0)\n payloadLength = first.SamplePayload.Length;\n', + ' var payloadLengths = asdus.Select(item => item.SamplePayload.Length).Distinct().ToArray();\n var payloadLength = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length;\n') + + view_model = 'src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs' + replace_once( + view_model, + 'using AR.Iec61850.SampledValues;\n', + 'using AR.Iec61850.SampledValues;\nusing AR.Iec61850.SampledValues.Profiles;\n') + replace_once( + view_model, + ' private readonly ConcurrentDictionary _runtimeStreams = new(StringComparer.OrdinalIgnoreCase);\n', + ' private readonly ConcurrentDictionary _runtimeStreams = new(StringComparer.Ordinal);\n private readonly SvStreamObservationManager _observationManager = new();\n') + replace_once( + view_model, + ' ObserveFrame(frame.Timestamp, frame.Frame);\n', + ' ObserveFrame(frame.Timestamp, frame.Frame, SvObservationInputKind.PcapReplay);\n') + replace_once( + view_model, + ' private void ObserveFrame(DateTimeOffset timestamp, ReadOnlyMemory ethernetFrame)\n', + ' private void ObserveFrame(\n DateTimeOffset timestamp,\n ReadOnlyMemory ethernetFrame,\n SvObservationInputKind inputKind)\n') + replace_once( + view_model, + ''' Interlocked.Increment(ref _svFrames);\n var first = frame.Pdu.Asdus.FirstOrDefault();\n var key = BuildStreamKey(frame, first?.SvId ?? string.Empty, first?.ConfigurationRevision);\n var runtime = _runtimeStreams.GetOrAdd(key, _ => new SvStreamRuntime(key));\n runtime.Observe(timestamp, frame, FindProfile(frame, first));\n''', + ''' Interlocked.Increment(ref _svFrames);\n var first = frame.Pdu.Asdus.FirstOrDefault();\n var profile = FindProfile(frame, first);\n if (!_observationManager.TryObserve(timestamp, frame, inputKind, profile, out var observation))\n {\n Interlocked.Increment(ref _parseErrors);\n return;\n }\n\n var key = observation.Key.Id;\n var runtime = _runtimeStreams.GetOrAdd(key, _ => new SvStreamRuntime(key));\n runtime.Observe(timestamp, frame, profile, observation);\n''') + replace_once( + view_model, + ' ObserveFrame(captured.Timestamp, captured.Frame);\n', + ' ObserveFrame(captured.Timestamp, captured.Frame, SvObservationInputKind.LiveCapture);\n') + replace_once( + view_model, + ''' private static string BuildStreamKey(SampledValuesFrame frame, string svId, uint? confRev)\n {\n var vlanText = frame.Vlan.HasValue ? frame.Vlan.Value.VlanId.ToString(CultureInfo.InvariantCulture) : "-";\n var confRevText = confRev.HasValue ? confRev.Value.ToString(CultureInfo.InvariantCulture) : "-";\n return $"SV|{frame.AppId:X4}|{frame.Source}|{frame.Destination}|{vlanText}|{svId}|{confRevText}";\n }\n\n''', + '') + replace_once( + view_model, + ' _runtimeStreams.Clear();\n', + ' _runtimeStreams.Clear();\n _observationManager.Clear();\n') + + runtime = 'src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs' + replace_once( + runtime, + 'using AR.Iec61850.SampledValues;\n', + 'using AR.Iec61850.SampledValues;\nusing AR.Iec61850.SampledValues.Profiles;\n') + replace_once( + runtime, + ' private IReadOnlyList _decodedValues = Array.Empty();\n', + ' private IReadOnlyList _decodedValues = Array.Empty();\n private SvStreamObservationSnapshot? _observationSnapshot;\n') + replace_once( + runtime, + ' public void Observe(DateTimeOffset timestamp, SampledValuesFrame frame, SampledValuesPublisherProfile? profile)\n', + ' public void Observe(\n DateTimeOffset timestamp,\n SampledValuesFrame frame,\n SampledValuesPublisherProfile? profile,\n SvStreamObservationSnapshot observationSnapshot)\n') + replace_once( + runtime, + ' _decodedValues = latestRows.ToArray();\n _lastHealthDetail = diagnostics.Count == 0 ? "SV stream is stable." : diagnostics[0];\n', + ' _decodedValues = latestRows.ToArray();\n _observationSnapshot = observationSnapshot;\n _lastHealthDetail = diagnostics.Count == 0 ? "SV stream is stable." : diagnostics[0];\n') + replace_once( + runtime, + ''' QualitySummary = _qualityGood + _qualityNonZero == 0\n ? "Quality not decoded"\n : $"Quality good {_qualityGood:N0}, non-zero {_qualityNonZero:N0}"\n''', + ''' QualitySummary = _qualityGood + _qualityNonZero == 0\n ? "Quality not decoded"\n : $"Quality good {_qualityGood:N0}, non-zero {_qualityNonZero:N0}",\n ObservationInputKinds = _observationSnapshot?.InputKinds ?? Array.Empty(),\n ObservationWindowFrames = _observationSnapshot?.Facts.ObservationCount ?? 0,\n ObservedFramesPerSecond = _observationSnapshot?.Facts.ObservedFramesPerSecond,\n ObservedSamplesPerSecond = _observationSnapshot?.Facts.ObservedSamplesPerSecond,\n ObservedCounterWrap = _observationSnapshot?.Facts.ObservedCounterWrap,\n ObservationDiagnostics = _observationSnapshot?.Diagnostics ?? Array.Empty(),\n FactProvenance = _observationSnapshot?.Facts.Provenance\n ?? new Dictionary(StringComparer.Ordinal)\n''') + + snapshot = 'src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs' + replace_once( + snapshot, + 'namespace ARSVIN.Subscriber.Models;\n', + 'using AR.Iec61850.SampledValues.Profiles;\n\nnamespace ARSVIN.Subscriber.Models;\n') + replace_once( + snapshot, + ' public string QualitySummary { get; init; } = string.Empty;\n', + ''' public string QualitySummary { get; init; } = string.Empty;\n public IReadOnlyList ObservationInputKinds { get; init; }\n = Array.Empty();\n public int ObservationWindowFrames { get; init; }\n public double? ObservedFramesPerSecond { get; init; }\n public double? ObservedSamplesPerSecond { get; init; }\n public int? ObservedCounterWrap { get; init; }\n public IReadOnlyList ObservationDiagnostics { get; init; } = Array.Empty();\n public IReadOnlyDictionary FactProvenance { get; init; }\n = new Dictionary(StringComparer.Ordinal);\n''') + PY + + rm .github/workflows/p3b-integrate.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'Integrate live and PCAP paths with shared observation pipeline' + git push origin HEAD:agent/p3b-observation-pipeline From 83d94285a656f7f82a388aab860e5427e50e834e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:15:28 +0000 Subject: [PATCH 04/12] Integrate live and PCAP paths with shared observation pipeline --- .github/workflows/p3b-integrate.yml | 113 ------------------ .../Profiles/SvStreamObservationManager.cs | 5 +- .../Models/SvStreamRuntime.cs | 19 ++- .../Models/SvStreamSnapshot.cs | 11 ++ .../ViewModels/SvSubscriberViewModel.cs | 32 +++-- 5 files changed, 49 insertions(+), 131 deletions(-) delete mode 100644 .github/workflows/p3b-integrate.yml diff --git a/.github/workflows/p3b-integrate.yml b/.github/workflows/p3b-integrate.yml deleted file mode 100644 index 75f7ff9..0000000 --- a/.github/workflows/p3b-integrate.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Temporary P3B Integration - -on: - push: - branches: [ agent/p3b-observation-pipeline ] - -permissions: - contents: write - -jobs: - integrate: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - steps: - - name: Checkout branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - ref: agent/p3b-observation-pipeline - fetch-depth: 0 - - - name: Wire live and PCAP paths into the shared observation manager - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - file = Path(path) - text = file.read_text(encoding='utf-8') - if old not in text: - raise SystemExit(f'Expected text not found in {path}: {old[:120]!r}') - file.write_text(text.replace(old, new, 1), encoding='utf-8', newline='\n') - - manager = 'src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs' - replace_once( - manager, - ' var payloadLength = asdus.Select(item => item.SamplePayload.Length).Distinct().SingleOrDefault();\n if (payloadLength <= 0)\n payloadLength = first.SamplePayload.Length;\n', - ' var payloadLengths = asdus.Select(item => item.SamplePayload.Length).Distinct().ToArray();\n var payloadLength = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length;\n') - - view_model = 'src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs' - replace_once( - view_model, - 'using AR.Iec61850.SampledValues;\n', - 'using AR.Iec61850.SampledValues;\nusing AR.Iec61850.SampledValues.Profiles;\n') - replace_once( - view_model, - ' private readonly ConcurrentDictionary _runtimeStreams = new(StringComparer.OrdinalIgnoreCase);\n', - ' private readonly ConcurrentDictionary _runtimeStreams = new(StringComparer.Ordinal);\n private readonly SvStreamObservationManager _observationManager = new();\n') - replace_once( - view_model, - ' ObserveFrame(frame.Timestamp, frame.Frame);\n', - ' ObserveFrame(frame.Timestamp, frame.Frame, SvObservationInputKind.PcapReplay);\n') - replace_once( - view_model, - ' private void ObserveFrame(DateTimeOffset timestamp, ReadOnlyMemory ethernetFrame)\n', - ' private void ObserveFrame(\n DateTimeOffset timestamp,\n ReadOnlyMemory ethernetFrame,\n SvObservationInputKind inputKind)\n') - replace_once( - view_model, - ''' Interlocked.Increment(ref _svFrames);\n var first = frame.Pdu.Asdus.FirstOrDefault();\n var key = BuildStreamKey(frame, first?.SvId ?? string.Empty, first?.ConfigurationRevision);\n var runtime = _runtimeStreams.GetOrAdd(key, _ => new SvStreamRuntime(key));\n runtime.Observe(timestamp, frame, FindProfile(frame, first));\n''', - ''' Interlocked.Increment(ref _svFrames);\n var first = frame.Pdu.Asdus.FirstOrDefault();\n var profile = FindProfile(frame, first);\n if (!_observationManager.TryObserve(timestamp, frame, inputKind, profile, out var observation))\n {\n Interlocked.Increment(ref _parseErrors);\n return;\n }\n\n var key = observation.Key.Id;\n var runtime = _runtimeStreams.GetOrAdd(key, _ => new SvStreamRuntime(key));\n runtime.Observe(timestamp, frame, profile, observation);\n''') - replace_once( - view_model, - ' ObserveFrame(captured.Timestamp, captured.Frame);\n', - ' ObserveFrame(captured.Timestamp, captured.Frame, SvObservationInputKind.LiveCapture);\n') - replace_once( - view_model, - ''' private static string BuildStreamKey(SampledValuesFrame frame, string svId, uint? confRev)\n {\n var vlanText = frame.Vlan.HasValue ? frame.Vlan.Value.VlanId.ToString(CultureInfo.InvariantCulture) : "-";\n var confRevText = confRev.HasValue ? confRev.Value.ToString(CultureInfo.InvariantCulture) : "-";\n return $"SV|{frame.AppId:X4}|{frame.Source}|{frame.Destination}|{vlanText}|{svId}|{confRevText}";\n }\n\n''', - '') - replace_once( - view_model, - ' _runtimeStreams.Clear();\n', - ' _runtimeStreams.Clear();\n _observationManager.Clear();\n') - - runtime = 'src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs' - replace_once( - runtime, - 'using AR.Iec61850.SampledValues;\n', - 'using AR.Iec61850.SampledValues;\nusing AR.Iec61850.SampledValues.Profiles;\n') - replace_once( - runtime, - ' private IReadOnlyList _decodedValues = Array.Empty();\n', - ' private IReadOnlyList _decodedValues = Array.Empty();\n private SvStreamObservationSnapshot? _observationSnapshot;\n') - replace_once( - runtime, - ' public void Observe(DateTimeOffset timestamp, SampledValuesFrame frame, SampledValuesPublisherProfile? profile)\n', - ' public void Observe(\n DateTimeOffset timestamp,\n SampledValuesFrame frame,\n SampledValuesPublisherProfile? profile,\n SvStreamObservationSnapshot observationSnapshot)\n') - replace_once( - runtime, - ' _decodedValues = latestRows.ToArray();\n _lastHealthDetail = diagnostics.Count == 0 ? "SV stream is stable." : diagnostics[0];\n', - ' _decodedValues = latestRows.ToArray();\n _observationSnapshot = observationSnapshot;\n _lastHealthDetail = diagnostics.Count == 0 ? "SV stream is stable." : diagnostics[0];\n') - replace_once( - runtime, - ''' QualitySummary = _qualityGood + _qualityNonZero == 0\n ? "Quality not decoded"\n : $"Quality good {_qualityGood:N0}, non-zero {_qualityNonZero:N0}"\n''', - ''' QualitySummary = _qualityGood + _qualityNonZero == 0\n ? "Quality not decoded"\n : $"Quality good {_qualityGood:N0}, non-zero {_qualityNonZero:N0}",\n ObservationInputKinds = _observationSnapshot?.InputKinds ?? Array.Empty(),\n ObservationWindowFrames = _observationSnapshot?.Facts.ObservationCount ?? 0,\n ObservedFramesPerSecond = _observationSnapshot?.Facts.ObservedFramesPerSecond,\n ObservedSamplesPerSecond = _observationSnapshot?.Facts.ObservedSamplesPerSecond,\n ObservedCounterWrap = _observationSnapshot?.Facts.ObservedCounterWrap,\n ObservationDiagnostics = _observationSnapshot?.Diagnostics ?? Array.Empty(),\n FactProvenance = _observationSnapshot?.Facts.Provenance\n ?? new Dictionary(StringComparer.Ordinal)\n''') - - snapshot = 'src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs' - replace_once( - snapshot, - 'namespace ARSVIN.Subscriber.Models;\n', - 'using AR.Iec61850.SampledValues.Profiles;\n\nnamespace ARSVIN.Subscriber.Models;\n') - replace_once( - snapshot, - ' public string QualitySummary { get; init; } = string.Empty;\n', - ''' public string QualitySummary { get; init; } = string.Empty;\n public IReadOnlyList ObservationInputKinds { get; init; }\n = Array.Empty();\n public int ObservationWindowFrames { get; init; }\n public double? ObservedFramesPerSecond { get; init; }\n public double? ObservedSamplesPerSecond { get; init; }\n public int? ObservedCounterWrap { get; init; }\n public IReadOnlyList ObservationDiagnostics { get; init; } = Array.Empty();\n public IReadOnlyDictionary FactProvenance { get; init; }\n = new Dictionary(StringComparer.Ordinal);\n''') - PY - - rm .github/workflows/p3b-integrate.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'Integrate live and PCAP paths with shared observation pipeline' - git push origin HEAD:agent/p3b-observation-pipeline diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs index ca837fe..96b8b3b 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvStreamObservationManager.cs @@ -164,9 +164,8 @@ public bool TryObserve( var diagnostics = ValidateFrameConsistency(asdus); var signature = profile?.Entries.Select(ToSignature).ToArray() ?? Array.Empty(); - var payloadLength = asdus.Select(item => item.SamplePayload.Length).Distinct().SingleOrDefault(); - if (payloadLength <= 0) - payloadLength = first.SamplePayload.Length; + var payloadLengths = asdus.Select(item => item.SamplePayload.Length).Distinct().ToArray(); + var payloadLength = payloadLengths.Length == 1 ? payloadLengths[0] : first.SamplePayload.Length; var observation = new SvFrameObservation { diff --git a/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs b/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs index 7f12379..8a68261 100644 --- a/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs +++ b/src/ARSVIN.Subscriber/Models/SvStreamRuntime.cs @@ -1,6 +1,7 @@ using AR.Iec61850.Mms; using System.Buffers.Binary; using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Profiles; using AR.Iec61850.Scl; namespace ARSVIN.Subscriber.Models; @@ -32,6 +33,7 @@ internal sealed class SvStreamRuntime private string _lastHealthDetail = string.Empty; private string _layoutBinding = string.Empty; private IReadOnlyList _decodedValues = Array.Empty(); + private SvStreamObservationSnapshot? _observationSnapshot; public SvStreamRuntime(string key) { @@ -55,7 +57,11 @@ public SvStreamRuntime(string key) public string ControlBlockReference { get; private set; } = string.Empty; public string LayoutBinding { get; private set; } = string.Empty; - public void Observe(DateTimeOffset timestamp, SampledValuesFrame frame, SampledValuesPublisherProfile? profile) + public void Observe( + DateTimeOffset timestamp, + SampledValuesFrame frame, + SampledValuesPublisherProfile? profile, + SvStreamObservationSnapshot observationSnapshot) { var asdus = frame.Pdu.Asdus; var first = asdus.FirstOrDefault(); @@ -168,6 +174,7 @@ public void Observe(DateTimeOffset timestamp, SampledValuesFrame frame, SampledV _diagnostics.Clear(); _diagnostics.AddRange(diagnostics.Take(12)); _decodedValues = latestRows.ToArray(); + _observationSnapshot = observationSnapshot; _lastHealthDetail = diagnostics.Count == 0 ? "SV stream is stable." : diagnostics[0]; } } @@ -227,7 +234,15 @@ public SvStreamSnapshot Snapshot() CursorSummary = BuildCursorSummary(visiblePoints), QualitySummary = _qualityGood + _qualityNonZero == 0 ? "Quality not decoded" - : $"Quality good {_qualityGood:N0}, non-zero {_qualityNonZero:N0}" + : $"Quality good {_qualityGood:N0}, non-zero {_qualityNonZero:N0}", + ObservationInputKinds = _observationSnapshot?.InputKinds ?? Array.Empty(), + ObservationWindowFrames = _observationSnapshot?.Facts.ObservationCount ?? 0, + ObservedFramesPerSecond = _observationSnapshot?.Facts.ObservedFramesPerSecond, + ObservedSamplesPerSecond = _observationSnapshot?.Facts.ObservedSamplesPerSecond, + ObservedCounterWrap = _observationSnapshot?.Facts.ObservedCounterWrap, + ObservationDiagnostics = _observationSnapshot?.Diagnostics ?? Array.Empty(), + FactProvenance = _observationSnapshot?.Facts.Provenance + ?? new Dictionary(StringComparer.Ordinal) }; } } diff --git a/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs b/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs index 39c6eb3..76a95a7 100644 --- a/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs +++ b/src/ARSVIN.Subscriber/Models/SvStreamSnapshot.cs @@ -1,3 +1,5 @@ +using AR.Iec61850.SampledValues.Profiles; + namespace ARSVIN.Subscriber.Models; public sealed class SvStreamSnapshot @@ -38,5 +40,14 @@ public sealed class SvStreamSnapshot public IReadOnlyList Phasors { get; init; } = Array.Empty(); public string CursorSummary { get; init; } = string.Empty; public string QualitySummary { get; init; } = string.Empty; + public IReadOnlyList ObservationInputKinds { get; init; } + = Array.Empty(); + public int ObservationWindowFrames { get; init; } + public double? ObservedFramesPerSecond { get; init; } + public double? ObservedSamplesPerSecond { get; init; } + public int? ObservedCounterWrap { get; init; } + public IReadOnlyList ObservationDiagnostics { get; init; } = Array.Empty(); + public IReadOnlyDictionary FactProvenance { get; init; } + = new Dictionary(StringComparer.Ordinal); } diff --git a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs index 77b3637..16bbcfc 100644 --- a/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs +++ b/src/ARSVIN.Subscriber/ViewModels/SvSubscriberViewModel.cs @@ -3,6 +3,7 @@ using System.Windows; using System.Windows.Threading; using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Profiles; using AR.Iec61850.Scl; using AR.Iec61850.Transports; using AR.Iec61850.Transports.Npcap; @@ -13,7 +14,8 @@ namespace ARSVIN.Subscriber.ViewModels; public sealed class SvSubscriberViewModel : ObservableObject, IDisposable { - private readonly ConcurrentDictionary _runtimeStreams = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _runtimeStreams = new(StringComparer.Ordinal); + private readonly SvStreamObservationManager _observationManager = new(); private readonly Dictionary _streamRows = new(StringComparer.OrdinalIgnoreCase); private readonly DispatcherTimer _uiTimer; private CancellationTokenSource? _captureCts; @@ -211,13 +213,16 @@ private int ReplayPcapFile(string path) foreach (var frame in PcapFrames.Read(path)) { total++; - ObserveFrame(frame.Timestamp, frame.Frame); + ObserveFrame(frame.Timestamp, frame.Frame, SvObservationInputKind.PcapReplay); } return total; } - private void ObserveFrame(DateTimeOffset timestamp, ReadOnlyMemory ethernetFrame) + private void ObserveFrame( + DateTimeOffset timestamp, + ReadOnlyMemory ethernetFrame, + SvObservationInputKind inputKind) { Interlocked.Increment(ref _rawFrames); if (!SampledValuesFrameParser.TryParseEthernetFrame(ethernetFrame, out var frame)) @@ -234,9 +239,16 @@ private void ObserveFrame(DateTimeOffset timestamp, ReadOnlyMemory etherne Interlocked.Increment(ref _svFrames); var first = frame.Pdu.Asdus.FirstOrDefault(); - var key = BuildStreamKey(frame, first?.SvId ?? string.Empty, first?.ConfigurationRevision); + var profile = FindProfile(frame, first); + if (!_observationManager.TryObserve(timestamp, frame, inputKind, profile, out var observation)) + { + Interlocked.Increment(ref _parseErrors); + return; + } + + var key = observation.Key.Id; var runtime = _runtimeStreams.GetOrAdd(key, _ => new SvStreamRuntime(key)); - runtime.Observe(timestamp, frame, FindProfile(frame, first)); + runtime.Observe(timestamp, frame, profile, observation); } private void ToggleCapture() @@ -290,7 +302,7 @@ private async Task CaptureLoopAsync(string adapterSelector, CancellationToken ca await foreach (var captured in source.CaptureAsync(options, cancellationToken).ConfigureAwait(false)) { - ObserveFrame(captured.Timestamp, captured.Frame); + ObserveFrame(captured.Timestamp, captured.Frame, SvObservationInputKind.LiveCapture); } } catch (OperationCanceledException) @@ -335,13 +347,6 @@ private bool PassesUserFilter(SampledValuesFrame frame) ?? _sclProfiles.FirstOrDefault(profile => string.Equals(profile.Stream.SvId, asdu.SvId, StringComparison.OrdinalIgnoreCase)); } - private static string BuildStreamKey(SampledValuesFrame frame, string svId, uint? confRev) - { - var vlanText = frame.Vlan.HasValue ? frame.Vlan.Value.VlanId.ToString(CultureInfo.InvariantCulture) : "-"; - var confRevText = confRev.HasValue ? confRev.Value.ToString(CultureInfo.InvariantCulture) : "-"; - return $"SV|{frame.AppId:X4}|{frame.Source}|{frame.Destination}|{vlanText}|{svId}|{confRevText}"; - } - private void RefreshUiSnapshots() { var snapshots = _runtimeStreams.Values.Select(x => x.Snapshot()).OrderBy(x => x.AppId).ThenBy(x => x.SvId).ToArray(); @@ -426,6 +431,7 @@ private void Clear() private void ClearRuntimeOnly() { _runtimeStreams.Clear(); + _observationManager.Clear(); _streamRows.Clear(); Streams.Clear(); SelectedValues.Clear(); From 13760a40df51abc6127f9c87a99406b3eb41f171 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:24:26 +0700 Subject: [PATCH 05/12] Add temporary P3B finalization workflow --- .github/workflows/p3b-finalize.yml | 107 +++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/p3b-finalize.yml diff --git a/.github/workflows/p3b-finalize.yml b/.github/workflows/p3b-finalize.yml new file mode 100644 index 0000000..07c5f4d --- /dev/null +++ b/.github/workflows/p3b-finalize.yml @@ -0,0 +1,107 @@ +name: Temporary P3B Finalization + +on: + push: + branches: [ agent/p3b-observation-pipeline ] + +permissions: + contents: write + +jobs: + finalize: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + steps: + - name: Checkout branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: agent/p3b-observation-pipeline + fetch-depth: 0 + + - name: Raise coverage gates and synchronize documentation + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + file = Path(path) + text = file.read_text(encoding='utf-8') + if old not in text: + raise SystemExit(f'Expected text not found in {path}: {old[:160]!r}') + file.write_text(text.replace(old, new, 1), encoding='utf-8', newline='\n') + + replace_once( + 'scripts/test-with-coverage.ps1', + '[double] $MinimumLineCoverage = 70,', + '[double] $MinimumLineCoverage = 72.5,') + replace_once( + 'scripts/test-with-coverage.ps1', + '[double] $MinimumWholeEngineLineCoverage = 13,', + '[double] $MinimumWholeEngineLineCoverage = 14.25,') + replace_once( + '.github/workflows/ci.yml', + '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 -NoRestore', + '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore') + + replace_once( + 'README.md', + '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70', + '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') + replace_once( + 'README.md', + 'The current baseline contains 74 deterministic tests. CI enforces the documented whole-engine and protocol-core coverage floors; current values are engineering regression evidence, not formal protocol conformance evidence.', + 'The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence.') + + replace_once( + 'docs/build-and-release.md', + '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70', + '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') + replace_once( + 'docs/build-and-release.md', + 'The documented baseline contains 74 deterministic tests, 13.35% whole-engine line coverage, and 70.47% protocol-core line coverage. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.', + 'The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.') + + replace_once( + 'docs/sv-profile-infrastructure.md', + '''Parsed live or PCAP frame\n ↓\nSvFrameObservation\n ↓\nSvObservationAccumulator\n ↓\nSvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer''', + '''Live Npcap frame ─┐\n ├── SvStreamObservationManager\nImported PCAP ────┘ ↓\n per-stream bounded window\n ↓\n SvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer''') + replace_once( + 'docs/sv-profile-infrastructure.md', + 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap.', + 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.') + replace_once( + 'docs/sv-profile-infrastructure.md', + '''## Next integration\n\n1. Feed parsed live and PCAP frames into `SvObservationAccumulator`.\n2. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n3. Present one compact profile/confidence state per observed stream.\n4. Show configuration mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.''', + '''## Current integration boundary\n\n`SvStreamObservationManager` now:\n\n- accepts parsed frames from live Npcap capture and classic-PCAP replay;\n- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference;\n- deliberately keeps `confRev` outside the key so revision changes remain observable;\n- retains input provenance (`LiveCapture` or `PcapReplay`);\n- adds SCL-derived dataset signatures when a stream is bound; and\n- exposes immutable rolling facts to the Subscriber runtime snapshot.\n\n## Next integration\n\n1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n2. Run strict or compatible configuration comparison per observed stream.\n3. Present one compact profile/confidence and SCL-match state per selected stream.\n4. Show evidence and mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.''') + + changelog = Path('CHANGELOG.md') + text = changelog.read_text(encoding='utf-8') + added_anchor = '- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence.\n' + added_new = added_anchor + '- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis.\n- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay.\n- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests.\n' + if added_anchor not in text: + raise SystemExit('CHANGELOG Added anchor not found') + text = text.replace(added_anchor, added_new, 1) + text = text.replace('- Expanded the deterministic suite from 54 to 74 tests.\n', '- Expanded the deterministic suite from 54 to 88 tests.\n', 1) + text = text.replace('- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%.\n', '- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%.\n', 1) + text = text.replace('- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%.\n', '- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%.\n', 1) + changed_anchor = '- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior.\n' + changed_new = changed_anchor + '- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately.\n- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes.\n' + if changed_anchor not in text: + raise SystemExit('CHANGELOG Changed anchor not found') + text = text.replace(changed_anchor, changed_new, 1) + marker = '\n## 0.4.0 — 2026-07-12\n' + fixed = '''\n### Fixed\n\n- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches.\n- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps.\n''' + if marker not in text: + raise SystemExit('CHANGELOG release marker not found') + text = text.replace(marker, fixed + marker, 1) + changelog.write_text(text, encoding='utf-8', newline='\n') + PY + + rm .github/workflows/p3b-finalize.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'Document unified observation pipeline and raise coverage gates' + git push origin HEAD:agent/p3b-observation-pipeline From 81bfb363fd52edec6c1777a18c1574c462046017 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:28:48 +0700 Subject: [PATCH 06/12] Run temporary P3B finalization on pull request updates --- .github/workflows/p3b-finalize.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/p3b-finalize.yml b/.github/workflows/p3b-finalize.yml index 07c5f4d..ebdbf68 100644 --- a/.github/workflows/p3b-finalize.yml +++ b/.github/workflows/p3b-finalize.yml @@ -3,6 +3,8 @@ name: Temporary P3B Finalization on: push: branches: [ agent/p3b-observation-pipeline ] + pull_request: + branches: [ main ] permissions: contents: write From 36867f02e5a12cad288632d61f13cf63f60df122 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:32:15 +0700 Subject: [PATCH 07/12] Make temporary P3B finalization replacements robust --- .github/workflows/p3b-finalize.yml | 115 +++++++++++++---------------- 1 file changed, 50 insertions(+), 65 deletions(-) diff --git a/.github/workflows/p3b-finalize.yml b/.github/workflows/p3b-finalize.yml index ebdbf68..4f958af 100644 --- a/.github/workflows/p3b-finalize.yml +++ b/.github/workflows/p3b-finalize.yml @@ -1,8 +1,6 @@ name: Temporary P3B Finalization on: - push: - branches: [ agent/p3b-observation-pipeline ] pull_request: branches: [ main ] @@ -26,78 +24,65 @@ jobs: set -euo pipefail python3 - <<'PY' from pathlib import Path + import re - def replace_once(path: str, old: str, new: str) -> None: + def sub(path: str, pattern: str, replacement: str, flags: int = 0) -> None: file = Path(path) text = file.read_text(encoding='utf-8') - if old not in text: - raise SystemExit(f'Expected text not found in {path}: {old[:160]!r}') - file.write_text(text.replace(old, new, 1), encoding='utf-8', newline='\n') + updated, count = re.subn(pattern, replacement, text, count=1, flags=flags) + if count != 1: + raise SystemExit(f'Expected one replacement in {path}, found {count}: {pattern}') + file.write_text(updated, encoding='utf-8', newline='\n') - replace_once( - 'scripts/test-with-coverage.ps1', - '[double] $MinimumLineCoverage = 70,', - '[double] $MinimumLineCoverage = 72.5,') - replace_once( - 'scripts/test-with-coverage.ps1', - '[double] $MinimumWholeEngineLineCoverage = 13,', - '[double] $MinimumWholeEngineLineCoverage = 14.25,') - replace_once( - '.github/workflows/ci.yml', - '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 -NoRestore', - '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore') + sub('scripts/test-with-coverage.ps1', r'\$MinimumLineCoverage = [0-9.]+,', '$MinimumLineCoverage = 72.5,') + sub('scripts/test-with-coverage.ps1', r'\$MinimumWholeEngineLineCoverage = [0-9.]+,', '$MinimumWholeEngineLineCoverage = 14.25,') + sub('.github/workflows/ci.yml', r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+ -NoRestore', '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore') + sub('README.md', r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') + sub('README.md', r'The current baseline contains 74 deterministic tests\.[^\n]*', 'The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence.') + sub('docs/build-and-release.md', r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') + sub('docs/build-and-release.md', r'The documented baseline contains 74 deterministic tests,[^\n]*', 'The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.') - replace_once( - 'README.md', - '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70', - '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') - replace_once( - 'README.md', - 'The current baseline contains 74 deterministic tests. CI enforces the documented whole-engine and protocol-core coverage floors; current values are engineering regression evidence, not formal protocol conformance evidence.', - 'The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence.') - - replace_once( - 'docs/build-and-release.md', - '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70', - '.\\scripts\\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') - replace_once( - 'docs/build-and-release.md', - 'The documented baseline contains 74 deterministic tests, 13.35% whole-engine line coverage, and 70.47% protocol-core line coverage. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.', - 'The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.') - - replace_once( - 'docs/sv-profile-infrastructure.md', - '''Parsed live or PCAP frame\n ↓\nSvFrameObservation\n ↓\nSvObservationAccumulator\n ↓\nSvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer''', - '''Live Npcap frame ─┐\n ├── SvStreamObservationManager\nImported PCAP ────┘ ↓\n per-stream bounded window\n ↓\n SvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer''') - replace_once( - 'docs/sv-profile-infrastructure.md', + profile = Path('docs/sv-profile-infrastructure.md') + text = profile.read_text(encoding='utf-8') + text, count = re.subn( + r'```text\nParsed live or PCAP frame.*?SvConfigurationComparer\n```', + '''```text\nLive Npcap frame ─┐\n ├── SvStreamObservationManager\nImported PCAP ────┘ ↓\n per-stream bounded window\n ↓\n SvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer\n```''', + text, + count=1, + flags=re.S) + if count != 1: + raise SystemExit(f'Profile data-flow replacement count: {count}') + text = text.replace( 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap.', - 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.') - replace_once( - 'docs/sv-profile-infrastructure.md', - '''## Next integration\n\n1. Feed parsed live and PCAP frames into `SvObservationAccumulator`.\n2. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n3. Present one compact profile/confidence state per observed stream.\n4. Show configuration mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.''', - '''## Current integration boundary\n\n`SvStreamObservationManager` now:\n\n- accepts parsed frames from live Npcap capture and classic-PCAP replay;\n- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference;\n- deliberately keeps `confRev` outside the key so revision changes remain observable;\n- retains input provenance (`LiveCapture` or `PcapReplay`);\n- adds SCL-derived dataset signatures when a stream is bound; and\n- exposes immutable rolling facts to the Subscriber runtime snapshot.\n\n## Next integration\n\n1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n2. Run strict or compatible configuration comparison per observed stream.\n3. Present one compact profile/confidence and SCL-match state per selected stream.\n4. Show evidence and mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.''') + 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.', + 1) + text, count = re.subn( + r'## Next integration\n.*?\Z', + '''## Current integration boundary\n\n`SvStreamObservationManager` now:\n\n- accepts parsed frames from live Npcap capture and classic-PCAP replay;\n- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference;\n- deliberately keeps `confRev` outside the key so revision changes remain observable;\n- retains input provenance (`LiveCapture` or `PcapReplay`);\n- adds SCL-derived dataset signatures when a stream is bound; and\n- exposes immutable rolling facts to the Subscriber runtime snapshot.\n\n## Next integration\n\n1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n2. Run strict or compatible configuration comparison per observed stream.\n3. Present one compact profile/confidence and SCL-match state per selected stream.\n4. Show evidence and mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.\n''', + text, + count=1, + flags=re.S) + if count != 1: + raise SystemExit(f'Profile next-integration replacement count: {count}') + profile.write_text(text, encoding='utf-8', newline='\n') changelog = Path('CHANGELOG.md') text = changelog.read_text(encoding='utf-8') - added_anchor = '- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence.\n' - added_new = added_anchor + '- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis.\n- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay.\n- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests.\n' - if added_anchor not in text: - raise SystemExit('CHANGELOG Added anchor not found') - text = text.replace(added_anchor, added_new, 1) - text = text.replace('- Expanded the deterministic suite from 54 to 74 tests.\n', '- Expanded the deterministic suite from 54 to 88 tests.\n', 1) - text = text.replace('- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%.\n', '- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%.\n', 1) - text = text.replace('- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%.\n', '- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%.\n', 1) - changed_anchor = '- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior.\n' - changed_new = changed_anchor + '- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately.\n- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes.\n' - if changed_anchor not in text: - raise SystemExit('CHANGELOG Changed anchor not found') - text = text.replace(changed_anchor, changed_new, 1) - marker = '\n## 0.4.0 — 2026-07-12\n' - fixed = '''\n### Fixed\n\n- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches.\n- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps.\n''' - if marker not in text: - raise SystemExit('CHANGELOG release marker not found') - text = text.replace(marker, fixed + marker, 1) + anchor = '- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence.\n' + if anchor not in text: + raise SystemExit('CHANGELOG added anchor missing') + text = text.replace(anchor, anchor + '- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis.\n- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay.\n- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests.\n', 1) + text = text.replace('- Expanded the deterministic suite from 54 to 74 tests.', '- Expanded the deterministic suite from 54 to 88 tests.', 1) + text = text.replace('- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%.', '- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%.', 1) + text = text.replace('- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%.', '- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%.', 1) + anchor = '- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior.\n' + if anchor not in text: + raise SystemExit('CHANGELOG changed anchor missing') + text = text.replace(anchor, anchor + '- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately.\n- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes.\n', 1) + release = '\n## 0.4.0 — 2026-07-12\n' + if release not in text: + raise SystemExit('CHANGELOG release marker missing') + text = text.replace(release, '\n### Fixed\n\n- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches.\n- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps.\n' + release, 1) changelog.write_text(text, encoding='utf-8', newline='\n') PY From b1cca76378e09ed5ac047d5ac661e04b22629f76 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:34:54 +0700 Subject: [PATCH 08/12] Apply tolerant P3B documentation finalization --- .github/workflows/p3b-finalize.yml | 115 +++++++++++++++-------------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/.github/workflows/p3b-finalize.yml b/.github/workflows/p3b-finalize.yml index 4f958af..15bf4c7 100644 --- a/.github/workflows/p3b-finalize.yml +++ b/.github/workflows/p3b-finalize.yml @@ -18,7 +18,7 @@ jobs: ref: agent/p3b-observation-pipeline fetch-depth: 0 - - name: Raise coverage gates and synchronize documentation + - name: Apply final P3B documentation and gate updates shell: bash run: | set -euo pipefail @@ -26,69 +26,72 @@ jobs: from pathlib import Path import re - def sub(path: str, pattern: str, replacement: str, flags: int = 0) -> None: + def edit(path, transform): file = Path(path) text = file.read_text(encoding='utf-8') - updated, count = re.subn(pattern, replacement, text, count=1, flags=flags) - if count != 1: - raise SystemExit(f'Expected one replacement in {path}, found {count}: {pattern}') - file.write_text(updated, encoding='utf-8', newline='\n') + file.write_text(transform(text), encoding='utf-8', newline='\n') - sub('scripts/test-with-coverage.ps1', r'\$MinimumLineCoverage = [0-9.]+,', '$MinimumLineCoverage = 72.5,') - sub('scripts/test-with-coverage.ps1', r'\$MinimumWholeEngineLineCoverage = [0-9.]+,', '$MinimumWholeEngineLineCoverage = 14.25,') - sub('.github/workflows/ci.yml', r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+ -NoRestore', '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore') - sub('README.md', r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') - sub('README.md', r'The current baseline contains 74 deterministic tests\.[^\n]*', 'The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence.') - sub('docs/build-and-release.md', r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5') - sub('docs/build-and-release.md', r'The documented baseline contains 74 deterministic tests,[^\n]*', 'The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.') + edit('scripts/test-with-coverage.ps1', lambda s: + re.sub(r'\$MinimumWholeEngineLineCoverage = [0-9.]+,', '$MinimumWholeEngineLineCoverage = 14.25,', + re.sub(r'\$MinimumLineCoverage = [0-9.]+,', '$MinimumLineCoverage = 72.5,', s, count=1), count=1)) - profile = Path('docs/sv-profile-infrastructure.md') - text = profile.read_text(encoding='utf-8') - text, count = re.subn( - r'```text\nParsed live or PCAP frame.*?SvConfigurationComparer\n```', - '''```text\nLive Npcap frame ─┐\n ├── SvStreamObservationManager\nImported PCAP ────┘ ↓\n per-stream bounded window\n ↓\n SvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer\n```''', - text, - count=1, - flags=re.S) - if count != 1: - raise SystemExit(f'Profile data-flow replacement count: {count}') - text = text.replace( - 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap.', - 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.', - 1) - text, count = re.subn( - r'## Next integration\n.*?\Z', - '''## Current integration boundary\n\n`SvStreamObservationManager` now:\n\n- accepts parsed frames from live Npcap capture and classic-PCAP replay;\n- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference;\n- deliberately keeps `confRev` outside the key so revision changes remain observable;\n- retains input provenance (`LiveCapture` or `PcapReplay`);\n- adds SCL-derived dataset signatures when a stream is bound; and\n- exposes immutable rolling facts to the Subscriber runtime snapshot.\n\n## Next integration\n\n1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n2. Run strict or compatible configuration comparison per observed stream.\n3. Present one compact profile/confidence and SCL-match state per selected stream.\n4. Show evidence and mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.\n''', - text, - count=1, - flags=re.S) - if count != 1: - raise SystemExit(f'Profile next-integration replacement count: {count}') - profile.write_text(text, encoding='utf-8', newline='\n') + edit('.github/workflows/ci.yml', lambda s: + re.sub(r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+ -NoRestore', + '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore', s, count=1)) - changelog = Path('CHANGELOG.md') - text = changelog.read_text(encoding='utf-8') - anchor = '- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence.\n' - if anchor not in text: - raise SystemExit('CHANGELOG added anchor missing') - text = text.replace(anchor, anchor + '- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis.\n- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay.\n- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests.\n', 1) - text = text.replace('- Expanded the deterministic suite from 54 to 74 tests.', '- Expanded the deterministic suite from 54 to 88 tests.', 1) - text = text.replace('- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%.', '- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%.', 1) - text = text.replace('- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%.', '- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%.', 1) - anchor = '- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior.\n' - if anchor not in text: - raise SystemExit('CHANGELOG changed anchor missing') - text = text.replace(anchor, anchor + '- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately.\n- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes.\n', 1) - release = '\n## 0.4.0 — 2026-07-12\n' - if release not in text: - raise SystemExit('CHANGELOG release marker missing') - text = text.replace(release, '\n### Fixed\n\n- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches.\n- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps.\n' + release, 1) - changelog.write_text(text, encoding='utf-8', newline='\n') + def readme(s): + s = re.sub(r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', + '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5', s, count=1) + return re.sub(r'The current baseline contains 74 deterministic tests\.[^\n]*', + 'The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence.', + s, count=1) + edit('README.md', readme) + + def build_doc(s): + s = re.sub(r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', + '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5', s, count=1) + return re.sub(r'The documented baseline contains 74 deterministic tests,[^\n]*', + 'The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.', + s, count=1) + edit('docs/build-and-release.md', build_doc) + + def profile_doc(s): + s = re.sub( + r'```text\nParsed live or PCAP frame.*?SvConfigurationComparer\n```', + '''```text\nLive Npcap frame ─┐\n ├── SvStreamObservationManager\nImported PCAP ────┘ ↓\n per-stream bounded window\n ↓\n SvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer\n```''', + s, count=1, flags=re.S) + s = s.replace( + 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap.', + 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.', 1) + return re.sub( + r'## Next integration\n.*\Z', + '''## Current integration boundary\n\n`SvStreamObservationManager` now:\n\n- accepts parsed frames from live Npcap capture and classic-PCAP replay;\n- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference;\n- deliberately keeps `confRev` outside the key so revision changes remain observable;\n- retains input provenance (`LiveCapture` or `PcapReplay`);\n- adds SCL-derived dataset signatures when a stream is bound; and\n- exposes immutable rolling facts to the Subscriber runtime snapshot.\n\n## Next integration\n\n1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n2. Run strict or compatible configuration comparison per observed stream.\n3. Present one compact profile/confidence and SCL-match state per selected stream.\n4. Show evidence and mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.\n''', + s, count=1, flags=re.S) + edit('docs/sv-profile-infrastructure.md', profile_doc) + + def changelog(s): + anchor = '- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence.\n' + if 'Added a shared per-stream observation manager' not in s: + s = s.replace(anchor, anchor + '- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis.\n- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay.\n- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests.\n', 1) + s = s.replace('- Expanded the deterministic suite from 54 to 74 tests.', '- Expanded the deterministic suite from 54 to 88 tests.', 1) + s = s.replace('- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%.', '- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%.', 1) + s = s.replace('- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%.', '- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%.', 1) + anchor = '- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior.\n' + if 'Research-candidate confidence is capped' not in s: + s = s.replace(anchor, anchor + '- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately.\n- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes.\n', 1) + if 'MAC-address formatting normalization is no longer applied' not in s: + s = s.replace('\n## 0.4.0 — 2026-07-12\n', '\n### Fixed\n\n- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches.\n- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps.\n\n## 0.4.0 — 2026-07-12\n', 1) + return s + edit('CHANGELOG.md', changelog) PY - rm .github/workflows/p3b-finalize.yml + git diff --check git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A + if git diff --quiet; then + echo 'No finalization changes were needed.' + exit 0 + fi + git add README.md CHANGELOG.md docs/build-and-release.md docs/sv-profile-infrastructure.md scripts/test-with-coverage.ps1 .github/workflows/ci.yml git commit -m 'Document unified observation pipeline and raise coverage gates' git push origin HEAD:agent/p3b-observation-pipeline From 6236c9cd607017d8b100141aba8dde4acace03e3 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:38:59 +0700 Subject: [PATCH 09/12] Raise P3B coverage regression floors --- scripts/test-with-coverage.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index 9d07f53..d95d7d4 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -1,10 +1,10 @@ [CmdletBinding()] param( [ValidateRange(0, 100)] - [double] $MinimumLineCoverage = 70, + [double] $MinimumLineCoverage = 72.5, [ValidateRange(0, 100)] - [double] $MinimumWholeEngineLineCoverage = 13, + [double] $MinimumWholeEngineLineCoverage = 14.25, [switch] $NoRestore ) From 48d2fe100bb1a9640c5439a8008bf4f47804e3b7 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:39:45 +0700 Subject: [PATCH 10/12] Enforce raised P3B coverage floors in CI --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac5f794..e4b18a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: - name: Test with whole-engine and protocol-core coverage gates shell: pwsh - run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 -NoRestore + run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore - name: Upload test and coverage evidence if: always() @@ -103,4 +103,4 @@ jobs: run: dotnet list src/ARSVIN/ARSVIN.csproj package --vulnerable --include-transitive - name: Subscriber dependency vulnerability report - run: dotnet list src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj package --vulnerable --include-transitive \ No newline at end of file + run: dotnet list src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj package --vulnerable --include-transitive From 3e6fc38dd208260757970189c6cbef9c1429951b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:39:57 +0000 Subject: [PATCH 11/12] Document unified observation pipeline and raise coverage gates --- CHANGELOG.md | 16 ++++++++++--- README.md | 4 ++-- docs/build-and-release.md | 4 ++-- docs/sv-profile-infrastructure.md | 38 +++++++++++++++++++------------ 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 166c557..f41282a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change - Added strict and compatible configuration-versus-wire comparison findings without blocking capture or decoding. - Added deterministic tests for observation windows, profile detection, evidence explanations, configuration mismatch handling, sparse-evidence confidence, and profile-definition provenance. - Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence. +- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis. +- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay. +- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests. ### Changed @@ -29,9 +32,16 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change - Public engineering workflow targets are described generically without proprietary product comparisons or branding. - The built-in profile catalog contains only the generic SCL-driven Layer-2 SV fallback until profile-specific requirements are verified. - Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior. -- Expanded the deterministic suite from 54 to 74 tests. -- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%. -- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%. +- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately. +- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes. +- Expanded the deterministic suite from 54 to 88 tests. +- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%. +- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%. + +### Fixed + +- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches. +- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps. ## 0.4.0 — 2026-07-12 diff --git a/README.md b/README.md index bcde65a..d892ddb 100644 --- a/README.md +++ b/README.md @@ -202,10 +202,10 @@ Build release artifacts except the installer: Run tests with both coverage gates: ```powershell -.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 +.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 ``` -The current baseline contains 74 deterministic tests. CI enforces the documented whole-engine and protocol-core coverage floors; current values are engineering regression evidence, not formal protocol conformance evidence. +The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence. ## Repository architecture diff --git a/docs/build-and-release.md b/docs/build-and-release.md index 0685ad2..3c6b502 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -63,12 +63,12 @@ Publisher, Subscriber, and Tests reference the same `ARSVIN.Engine` assembly. Pr ## Coverage evidence ```powershell -.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 +.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 ``` The coverage workflow retains TRX, full test logs, and Cobertura output under `artifacts/test-results`, then enforces whole-engine and protocol-core regression floors. -The documented baseline contains 74 deterministic tests, 13.35% whole-engine line coverage, and 70.47% protocol-core line coverage. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence. +The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence. ## Build and validate the public site diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md index 691a7ba..d0d8ff5 100644 --- a/docs/sv-profile-infrastructure.md +++ b/docs/sv-profile-infrastructure.md @@ -5,18 +5,17 @@ ARSVIN separates observed wire facts, configured expectations, and profile claim ## Data flow ```text -Parsed live or PCAP frame - ↓ -SvFrameObservation - ↓ -SvObservationAccumulator - ↓ -SvObservedStreamFacts - ├── SvProfileDetector - └── SvConfigurationComparer +Live Npcap frame ─┐ + ├── SvStreamObservationManager +Imported PCAP ────┘ ↓ + per-stream bounded window + ↓ + SvObservedStreamFacts + ├── SvProfileDetector + └── SvConfigurationComparer ``` -The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. +The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract. ## Observed facts @@ -85,10 +84,21 @@ Two modes are available: Neither mode stops receive-side capture or decoding. Unknown and conflicting streams remain visible. +## Current integration boundary + +`SvStreamObservationManager` now: + +- accepts parsed frames from live Npcap capture and classic-PCAP replay; +- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference; +- deliberately keeps `confRev` outside the key so revision changes remain observable; +- retains input provenance (`LiveCapture` or `PcapReplay`); +- adds SCL-derived dataset signatures when a stream is bound; and +- exposes immutable rolling facts to the Subscriber runtime snapshot. + ## Next integration -1. Feed parsed live and PCAP frames into `SvObservationAccumulator`. -2. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`. -3. Present one compact profile/confidence state per observed stream. -4. Show configuration mismatch details on demand rather than adding permanent visual noise. +1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`. +2. Run strict or compatible configuration comparison per observed stream. +3. Present one compact profile/confidence and SCL-match state per selected stream. +4. Show evidence and mismatch details on demand rather than adding permanent visual noise. 5. Add profile-specific definitions only after source review and deterministic evidence. From a0f0ad5ff7dd47520ff6c9e36f820f262440567d Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 15 Jul 2026 19:41:04 +0700 Subject: [PATCH 12/12] Remove temporary P3B finalization workflow --- .github/workflows/p3b-finalize.yml | 97 ------------------------------ 1 file changed, 97 deletions(-) delete mode 100644 .github/workflows/p3b-finalize.yml diff --git a/.github/workflows/p3b-finalize.yml b/.github/workflows/p3b-finalize.yml deleted file mode 100644 index 15bf4c7..0000000 --- a/.github/workflows/p3b-finalize.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Temporary P3B Finalization - -on: - pull_request: - branches: [ main ] - -permissions: - contents: write - -jobs: - finalize: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - steps: - - name: Checkout branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - ref: agent/p3b-observation-pipeline - fetch-depth: 0 - - - name: Apply final P3B documentation and gate updates - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - import re - - def edit(path, transform): - file = Path(path) - text = file.read_text(encoding='utf-8') - file.write_text(transform(text), encoding='utf-8', newline='\n') - - edit('scripts/test-with-coverage.ps1', lambda s: - re.sub(r'\$MinimumWholeEngineLineCoverage = [0-9.]+,', '$MinimumWholeEngineLineCoverage = 14.25,', - re.sub(r'\$MinimumLineCoverage = [0-9.]+,', '$MinimumLineCoverage = 72.5,', s, count=1), count=1)) - - edit('.github/workflows/ci.yml', lambda s: - re.sub(r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+ -NoRestore', - '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore', s, count=1)) - - def readme(s): - s = re.sub(r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', - '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5', s, count=1) - return re.sub(r'The current baseline contains 74 deterministic tests\.[^\n]*', - 'The current baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% across 16,582 instrumented lines, while protocol-core coverage is 72.76% across 2,390 lines. CI enforces floors of 14.25% and 72.5% respectively; these values are engineering regression evidence, not formal protocol conformance evidence.', - s, count=1) - edit('README.md', readme) - - def build_doc(s): - s = re.sub(r'-MinimumWholeEngineLineCoverage [0-9.]+ -MinimumLineCoverage [0-9.]+', - '-MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5', s, count=1) - return re.sub(r'The documented baseline contains 74 deterministic tests,[^\n]*', - 'The documented baseline contains 88 deterministic tests. Whole-engine coverage is 14.61% with 2,423 of 16,582 lines covered, and protocol-core coverage is 72.76% with 1,739 of 2,390 lines covered. CI enforces floors of 14.25% and 72.5%. These values are regression evidence, not formal conformance, complete live-network validation, deterministic timing evidence, or universal device-interoperability evidence.', - s, count=1) - edit('docs/build-and-release.md', build_doc) - - def profile_doc(s): - s = re.sub( - r'```text\nParsed live or PCAP frame.*?SvConfigurationComparer\n```', - '''```text\nLive Npcap frame ─┐\n ├── SvStreamObservationManager\nImported PCAP ────┘ ↓\n per-stream bounded window\n ↓\n SvObservedStreamFacts\n ├── SvProfileDetector\n └── SvConfigurationComparer\n```''', - s, count=1, flags=re.S) - s = s.replace( - 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap.', - 'The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. Subscriber live capture and PCAP replay now use the same manager and stable stream identity, so both paths produce the same facts and diagnostics contract.', 1) - return re.sub( - r'## Next integration\n.*\Z', - '''## Current integration boundary\n\n`SvStreamObservationManager` now:\n\n- accepts parsed frames from live Npcap capture and classic-PCAP replay;\n- groups frames by source, destination, VLAN, APPID, `svID`, and dataset reference;\n- deliberately keeps `confRev` outside the key so revision changes remain observable;\n- retains input provenance (`LiveCapture` or `PcapReplay`);\n- adds SCL-derived dataset signatures when a stream is bound; and\n- exposes immutable rolling facts to the Subscriber runtime snapshot.\n\n## Next integration\n\n1. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`.\n2. Run strict or compatible configuration comparison per observed stream.\n3. Present one compact profile/confidence and SCL-match state per selected stream.\n4. Show evidence and mismatch details on demand rather than adding permanent visual noise.\n5. Add profile-specific definitions only after source review and deterministic evidence.\n''', - s, count=1, flags=re.S) - edit('docs/sv-profile-infrastructure.md', profile_doc) - - def changelog(s): - anchor = '- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence.\n' - if 'Added a shared per-stream observation manager' not in s: - s = s.replace(anchor, anchor + '- Added bounded, thread-safe SV observation windows with fact provenance and explicit sequential, gap, duplicate, out-of-order/reset, and confirmed-wrap analysis.\n- Added a shared per-stream observation manager used identically by live Npcap capture and PCAP replay.\n- Added stable stream identity, input-source tracking, SCL-derived dataset signatures, immutable observation snapshots, and deterministic live/PCAP pipeline tests.\n', 1) - s = s.replace('- Expanded the deterministic suite from 54 to 74 tests.', '- Expanded the deterministic suite from 54 to 88 tests.', 1) - s = s.replace('- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%.', '- Raised whole-engine coverage from 10.74% to 14.61% and the enforced floor from 10.5% to 14.25%.', 1) - s = s.replace('- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%.', '- Raised protocol-core coverage from 64.97% to 72.76% and the enforced floor from 60% to 72.5%.', 1) - anchor = '- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior.\n' - if 'Research-candidate confidence is capped' not in s: - s = s.replace(anchor, anchor + '- Research-candidate confidence is capped at `Possible`, generic implemented profiles are capped at `Likely`, and raw detector confidence remains available separately.\n- Subscriber live and PCAP paths now share one observation pipeline and no longer split a stream solely because `confRev` changes.\n', 1) - if 'MAC-address formatting normalization is no longer applied' not in s: - s = s.replace('\n## 0.4.0 — 2026-07-12\n', '\n### Fixed\n\n- MAC-address formatting normalization is no longer applied to `svID` or dataset references, preventing punctuation-related false matches.\n- Backward sample-counter transitions require zero plus sequential recovery before they are classified as wraps.\n\n## 0.4.0 — 2026-07-12\n', 1) - return s - edit('CHANGELOG.md', changelog) - PY - - git diff --check - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - if git diff --quiet; then - echo 'No finalization changes were needed.' - exit 0 - fi - git add README.md CHANGELOG.md docs/build-and-release.md docs/sv-profile-infrastructure.md scripts/test-with-coverage.ps1 .github/workflows/ci.yml - git commit -m 'Document unified observation pipeline and raise coverage gates' - git push origin HEAD:agent/p3b-observation-pipeline