Private/Kinds/StabilityNetwork.ps1

# The tie between a program's crashes and the network: a part of the Stability Kind, and
# no Kind.
#
# What the network Checks before it gathered - the disruptions, what depends on a Server,
# the adapters - is copied onto the Stability data by its Gatherer, and held here against
# each crash: which Server dropped around it, whether the program depends on that Server,
# and how much weight the coincidence carries.

# How many seconds after a crash a network disruption may lie and still be held against it
# - and, for a program known to be local, before it as well. The SMB client notices a lost
# connection when it next needs it, and a program dies when it next needs a page: neither
# happens in the second the cable did.
$script:StabilityCorrelationWindowSeconds = 120

# How many seconds before a crash a disruption may lie for a program that is not known to
# be local. A program on a share runs on with what it has in memory when the connection
# drops, and dies when it next needs a page of itself from the share: on the machine this
# was built for, four to seven minutes later. On a second machine every one of eleven
# crashes followed a drop, by two to twenty-seven minutes - as long as it took the person
# at it to do something the program had not yet in memory - and ten minutes saw seven of
# them. Half an hour.
$script:StabilityCorrelationBeforeSeconds = 1800

# How many seconds after a crash a reconnect to the program's Server may lie and still be
# held against it. The access that killed the program is the one Windows re-establishes
# the session on: there, twelve to forty-eight seconds after the crash.
$script:StabilityCorrelationReconnectSeconds = 120

function Copy-ObservedNetworkDisruption {
    <#
    .SYNOPSIS
        The network disruptions an earlier Check gathered, as this Kind's own data. Pure,
        and decides nothing.
    .DESCRIPTION
        What comes from the Elevated Part crossed between two processes as CliXML, so
        every property is asked for and none is assumed. Gathered is false when the Kind
        is not there at all, which is the only thing that tells a Run that could not look
        from a network that was quiet.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Observed)

    $source = Get-ObservedKindData -Observed $Observed -Kind 'NetworkDisruption'

    [pscustomobject]@{
        Gathered       = $null -ne $source
        Disruptions    = @(foreach ($row in (Get-DataCollection $source 'Disruptions')) {
            [pscustomobject]@{
                Time     = Get-DataProperty $row 'Time'
                Server   = Get-DataProperty $row 'Server'
                Kind     = Get-DataProperty $row 'Kind'
                Provider = Get-DataProperty $row 'Provider'
                Id       = Get-DataProperty $row 'Id'
            }
        })
        UnreadableLogs = @((Get-DataCollection $source 'UnreadableLogs') | ForEach-Object { "$_" })
        # Every log that Check queried. $null, and not empty, when it did not say: a
        # NetworkDisruption Kind older than the list, which is then not held against it.
        ScannedLogs    = Copy-ObservedList -InputObject $source -Name 'ScannedLogs'
        # How far back it looked, and how far back each log still reaches: a crash from
        # before either has not been held against anything.
        Days           = Get-DataProperty $source 'Days'
        Since          = Get-DataProperty $source 'Since'
        LogCoverage    = @(foreach ($row in (Get-DataCollection $source 'LogCoverage')) {
            [pscustomobject]@{
                Log    = "$(Get-DataProperty $row 'Log')"
                Oldest = Get-DataProperty $row 'Oldest'
            }
        })
    }
}

function Copy-ObservedNetworkDependency {
    <#
    .SYNOPSIS
        The inventory of what depends on which Server, and the drive mapping it was
        resolved with, as this Kind's own data. Pure, and decides nothing.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Observed)

    $source = Get-ObservedKindData -Observed $Observed -Kind 'NetworkDependency'

    [pscustomobject]@{
        Gathered      = $null -ne $source
        Dependencies  = @(foreach ($row in (Get-DataCollection $source 'Dependencies')) {
            [pscustomobject]@{
                Application = Get-DataProperty $row 'Application'
                Path        = Get-DataProperty $row 'Path'
                UncPath     = Get-DataProperty $row 'UncPath'
                Server      = Get-DataProperty $row 'Server'
                Kind        = Get-DataProperty $row 'Kind'
                Source      = Get-DataProperty $row 'Source'
            }
        })
        DriveMappings = @(foreach ($row in (Get-DataCollection $source 'DriveMappings')) {
            [pscustomobject]@{
                LocalPath  = Get-DataProperty $row 'LocalPath'
                RemotePath = Get-DataProperty $row 'RemotePath'
            }
        })
        # The drive letters that are disks of this machine. $null, and not empty, when
        # the inventory did not say: a drive letter that is neither mapped nor listed
        # here is then taken for local, as it was before anybody listed them.
        LocalDrives   = Copy-ObservedList -InputObject $source -Name 'LocalDrives'
    }
}

function Copy-ObservedAdapter {
    <#
    .SYNOPSIS
        Which of the machine's adapters are on USB and what their power saving is set to,
        as this Kind's own data. Pure, and decides nothing.
    .DESCRIPTION
        From the Network Check, which lists the adapters that are up. A fact that Check
        did not gather - a Network Kind older than the adapter facts - stays unknown here
        and does not become "no".
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][hashtable]$Observed)

    $source = Get-ObservedKindData -Observed $Observed -Kind 'Network'

    [pscustomobject]@{
        Gathered            = $null -ne $source
        Adapters            = @(foreach ($row in (Get-DataCollection $source 'Adapters')) {
            [pscustomobject]@{
                Name          = Get-DataProperty $row 'Name'
                Description   = Get-DataProperty $row 'Description'
                IsUsb         = Get-DataProperty $row 'IsUsb'
                PowerSavingOn = Get-DataProperty $row 'PowerSavingOn'
            }
        })
        UsbSelectiveSuspend = Get-DataProperty $source 'UsbSelectiveSuspend'
    }
}

function Get-CorrelationSetting {
    <#
    .SYNOPSIS
        What a crash is held against: how far before and after it a network disruption
        may lie, which disruptions count, what counts as a reconnect and how long after,
        and which logs name the Server. Pure.
    .DESCRIPTION
        All of them are Check Definition parameters. Which events are a disruption at all
        is the NetworkDisruption Check's Definition; which of those can end a program is
        asked here, and is not the same question: the Wi-Fi leaving a network is context in
        a list of what the network did, and the end of a program that ran from a share.
 
        Three windows. WindowSeconds (CorrelationWindowSeconds) is how long after a crash
        a disruption counts, and for a program known to be local how long before it as
        well. BeforeSeconds (CorrelationBeforeSeconds) is how long before a crash it
        counts for every other program: the connection drops, and the program dies
        minutes later. ReconnectSeconds (CorrelationReconnectSeconds) is how long after a
        crash a reconnect counts, which is one of ReconnectKinds.
 
        The logs are the SMB client's, which name the Server a connection was lost to.
        With one of them not read, a crash that matches nothing has not been cleared.
 
        A window that is no number, or a list that names nothing, is the Kind's own: a
        Definition is a document somebody edits, and a slip in it must neither throw nor
        switch the question off.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $window = Get-NumberParameter $Parameters 'CorrelationWindowSeconds' $script:StabilityCorrelationWindowSeconds
    if ($window -lt 0) { $window = $script:StabilityCorrelationWindowSeconds }

    $before = Get-NumberParameter $Parameters 'CorrelationBeforeSeconds' $script:StabilityCorrelationBeforeSeconds
    if ($before -lt 0) { $before = $script:StabilityCorrelationBeforeSeconds }

    $reconnect = Get-NumberParameter $Parameters 'CorrelationReconnectSeconds' $script:StabilityCorrelationReconnectSeconds
    if ($reconnect -lt 0) { $reconnect = $script:StabilityCorrelationReconnectSeconds }

    # What says that a connection to a Server came back. Context in the list of what the
    # network did, and evidence when it follows a crash: see Select-IncidentReconnect.
    $reconnectKinds = Get-ListParameter $Parameters 'ReconnectKinds' @('smb-reconnect')

    # An empty list is not "nothing counts": with no kind to hold a crash against, every
    # program would read as cleared. It is a Definition that left the list out.
    # A refused sign-in or a refused share is not in here: the Server answered, and said
    # no. On a domain PC one scheduled job asking for a share it may not read logs that
    # twice a day, and counted, it stood next to every crash that happened at that hour.
    $kinds = Get-ListParameter $Parameters 'CorrelationKinds' @(
        'smb-disconnect', 'smb-timeout', 'smb-connect-failed', 'smb-handle-lost',
        'link-down', 'network-disconnected', 'wlan-disconnect', 'adapter-reset')

    # What can be held against a program that is known to be local: the machine losing
    # its network, and nothing else. That some Server did not answer says nothing about a
    # program that asks no Server for anything.
    $localKinds = Get-ListParameter $Parameters 'LocalCorrelationKinds' @(
        'link-down', 'network-disconnected', 'wlan-disconnect', 'adapter-reset')

    $logs = Get-ListParameter $Parameters 'ServerEventLogs' @(
        'Microsoft-Windows-SmbClient/Connectivity', 'Microsoft-Windows-SMBClient/Operational',
        'Microsoft-Windows-SmbClient/Security')

    [pscustomobject]@{
        WindowSeconds    = $window
        BeforeSeconds    = $before
        ReconnectSeconds = $reconnect
        Kinds            = @($kinds)
        LocalKinds       = @($localKinds)
        ReconnectKinds   = @($reconnectKinds)
        ServerLogs       = @($logs)
    }
}

function Get-ProgramNetwork {
    <#
    .SYNOPSIS
        Where a program runs from, as far as the network is concerned: each of its paths
        with the share and the Server behind it, and the Servers it depends on. Pure.
    .DESCRIPTION
        A drive letter is resolved with the mapping the NetworkDependency Check gathered,
        in the session of the person whose drives they are; a UNC path names its Server
        itself. A path the Gatherer found to be on the network and no mapping explains is
        on the network all the same, on a Server nobody can name.
 
        A drive letter that is neither mapped nor one of the machine's disks, where the
        inventory listed those, is not known to be either: the Main Part started elevated
        does not see the drives of the person at the desk. Such a path is placed nowhere,
        and UnknownDrives names its letter.
 
        The Servers are those of the program's own paths and of what the inventory lists
        for it as a program - a module it loads from a share is the program, too.
    .PARAMETER NetworkDependency
        This Kind's copy of what the NetworkDependency Check gathered. May be absent.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowEmptyString()][string]$App = '',
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowNull()]$NetworkDependency
    )

    $mapping = Get-DataCollection $NetworkDependency 'DriveMappings'
    $servers = @()

    # The disks of this machine, where the inventory listed them. Only then can a drive
    # letter be neither: not mapped, not a disk, and so not known to be either.
    $disksListed = Test-DataCarried -InputObject $NetworkDependency -Name 'LocalDrives'
    $disks       = @((Get-DataCollection $NetworkDependency 'LocalDrives') | ForEach-Object {
        if ("$_" -match '^\s*([A-Za-z])') { '{0}:' -f $Matches[1].ToUpperInvariant() }
    })
    $unplaced    = @()

    # A program can have been started from more than one place in a month.
    $paths     = @()
    $remote    = @{}
    foreach ($row in @($Incident | Where-Object { $null -ne $_ })) {
        $path = "$(Get-DataProperty $row 'AppPath')".Trim()
        if (-not $path) { continue }
        if ($paths -notcontains $path) { $paths += $path; $remote[$path] = $false }
        if ([bool](Get-DataProperty $row 'Remote')) { $remote[$path] = $true }
    }

    $locations = @(foreach ($path in $paths) {
        $unc    = "$(ConvertTo-NetworkDependencyUncPath -Path $path -DriveMapping $mapping)"
        $share  = ''
        $server = ''
        if ($unc) {
            $parts = Split-NetworkDependencyUncPath -UncPath $unc
            if ($parts) { $share = "$($parts.Share)"; $server = "$($parts.Server)" }
        }
        $drive = ''
        if ($path -match '^([A-Za-z]):') { $drive = '{0}:' -f $Matches[1].ToUpperInvariant() }

        if ($server -and -not @($servers | Where-Object { Test-ServerNameMatch -Name $_ -Other $server }).Count) { $servers += $server }

        # Network: a UNC path, a mapped drive, or what the Gatherer found to be remote.
        # Local: a disk of this machine. Unknown: a drive letter that is neither - the
        # drive of somebody whose session this Part did not run in, or one no longer
        # connected. Where nobody listed the disks, a drive letter is local, as it was.
        $placement = 'Local'
        if ($remote[$path] -or $unc) { $placement = 'Network' }
        elseif ($disksListed -and $drive -and $disks -notcontains $drive) {
            $placement = 'Unknown'
            if ($unplaced -notcontains $drive) { $unplaced += $drive }
        }

        [pscustomobject]@{
            Path      = $path
            Remote    = [bool]($placement -eq 'Network')
            Share     = $share
            Server    = $server
            Drive     = $drive
            Placement = $placement
        }
    })

    $key = ConvertTo-ProgramKey $App
    $dependencies = @((Get-DataCollection $NetworkDependency 'Dependencies') | Where-Object {
        $key -and "$(Get-DataProperty $_ 'Kind')" -eq 'program' -and
        (ConvertTo-ProgramKey (Get-DataProperty $_ 'Application')) -eq $key
    })
    foreach ($dependency in $dependencies) {
        $server = "$(Get-DataProperty $dependency 'Server')".Trim()
        if ($server -and -not @($servers | Where-Object { Test-ServerNameMatch -Name $_ -Other $server }).Count) { $servers += $server }
    }

    # The network places the program or a module of it lives in, as a Technician names
    # them: the share, or the path as the event wrote it when nothing says which share.
    $places = @()
    foreach ($location in @($locations | Where-Object { $_.Remote })) {
        $place = $location.Path
        if ($location.Share) { $place = $location.Share }
        if ($places -notcontains $place) { $places += $place }
    }
    foreach ($dependency in $dependencies) {
        $place = "$(Get-DataProperty $dependency 'UncPath')".Trim()
        $parts = Split-NetworkDependencyUncPath -UncPath $place
        if ($parts) { $place = "$($parts.Share)" }
        if (-not $place) { $place = "$(Get-DataProperty $dependency 'Server')".Trim() }
        if ($place -and $places -notcontains $place) { $places += $place }
    }

    $inventoried = [bool](Get-DataProperty $NetworkDependency 'Gathered')

    [pscustomobject]@{
        Locations     = $locations
        Dependencies  = $dependencies
        # Whether the program itself runs from the network, which is what decides whether
        # a disruption to another Server can be left out.
        OnNetwork     = [bool]@($locations | Where-Object { $_.Remote }).Count
        Servers       = [string[]]$servers
        Places        = [string[]]$places
        # Whether anybody took the inventory. Without it, a program whose own path is
        # local has not been cleared: nobody looked at what it loads.
        Inventoried   = $inventoried
        # The drive letters of its paths that are neither mapped nor a disk of this
        # machine. Where there is one, nothing says whether the program is on the network.
        UnknownDrives = [string[]]$unplaced
        # Whether the program is known to be a local one: nothing of it on the network,
        # no drive that could not be placed, and either a path of its own or an inventory
        # that would have listed it. What a chance coincidence is measured by - see
        # Test-CorrelationWeight.
        KnownLocal    = [bool](-not $places.Count -and -not $unplaced.Count -and ($locations.Count -or $inventoried))
    }
}

function Test-ServerNameMatch {
    <#
    .SYNOPSIS
        Whether two names are the same Server. Pure.
    .DESCRIPTION
        A drive is mapped to \\FS01 and the SMB client logs fs01.example.internal: one
        Server. A short name is the first label of a full one. Two full names are the same
        only when all of each is, and an address is only itself - nothing here can know
        which name it has.
 
        True only where the names say so. That two names are not known to be the same
        Server does not make them two Servers: see Compare-ServerName.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Name, [AllowNull()][AllowEmptyString()][string]$Other)

    (Compare-ServerName -Program $Name -Named $Other) -eq 'Same'
}

function ConvertTo-ServerNameKey {
    <#
    .SYNOPSIS
        A Server's name as something to compare by: no leading backslashes, no brackets
        around an address, no trailing dot, lower case. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Name)

    "$Name".Trim().TrimStart('\').Trim('[', ']').TrimEnd('.').ToLowerInvariant()
}

function Test-ServerAddress {
    <#
    .SYNOPSIS
        Whether a Server is named by its address and not by a name. Pure.
    .DESCRIPTION
        IPv4 as four numbers; IPv6 by its colons, or as the name a UNC path writes it by,
        which may hold none: fe80--1.ipv6-literal.net.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Name)

    $key = ConvertTo-ServerNameKey $Name
    [bool]($key -match '^\d{1,3}(\.\d{1,3}){3}$' -or $key -match ':' -or $key -match '\.ipv6-literal\.net$')
}

function Compare-ServerName {
    <#
    .SYNOPSIS
        Whether the Server an event names is the Server a program runs from: Same,
        Different, or Unknown. Pure.
    .DESCRIPTION
        Three answers, because two names that differ are not two Servers. A drive mapped
        to \\corp.example\dfs\apps runs from whichever Server the namespace hands out, and
        the SMB client logs that Server's own name; a drive mapped to an address runs
        from a Server whose name nothing here can know. Only Different lets a disruption
        be left out, so only what the names can really tell apart is Different.
 
          the program's Server the event's Server answer
          ------------------------- --------------------------------- ---------
          any the same, whatever the case Same
          an address anything else Unknown
          any an address, and not the same one Unknown
          one label FS01 one label, another NAS02 Different
          one label FS01 full, first label FS01 Same
          one label CORP full, a later label CORP Unknown
                                     (fs01.corp.example: CORP may be the short name of
                                     the domain, and \\CORP\dfs a namespace in it)
          one label FS01 full, no label FS01 Different
          several labels fs01.a.b one label, FS01 Same
          several labels anything else Unknown
                                     (corp.example may be a domain and its namespace,
                                     behind which any Server can stand - and nothing
                                     tells fs01.corp.example from a domain of that name)
 
        Not symmetric, and not meant to be: it is the program's name that may stand for
        more than one Server.
 
        What stays out of reach: a namespace or an alias with a name of one label. A
        drive mapped to \\DFS01\root that is served by FS01 reads as Different, and
        nothing in two names says otherwise.
    .PARAMETER Program
        The Server as the program's path names it.
    .PARAMETER Named
        The Server as the event names it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Program,
        [AllowNull()][AllowEmptyString()][string]$Named
    )

    $mine   = ConvertTo-ServerNameKey $Program
    $theirs = ConvertTo-ServerNameKey $Named
    if (-not $mine -or -not $theirs) { return 'Unknown' }
    if ($mine -eq $theirs) { return 'Same' }

    if ((Test-ServerAddress $mine) -or (Test-ServerAddress $theirs)) { return 'Unknown' }

    $mineLabels   = @($mine -split '\.')
    $theirsLabels = @($theirs -split '\.')

    if ($mineLabels.Count -gt 1) {
        if ($theirsLabels.Count -eq 1 -and $theirsLabels[0] -eq $mineLabels[0]) { return 'Same' }
        return 'Unknown'
    }

    if ($theirsLabels[0] -eq $mine) { return 'Same' }
    if ($theirsLabels.Count -gt 1 -and $theirsLabels -contains $mine) { return 'Unknown' }
    'Different'
}

function Get-ServerAttribution {
    <#
    .SYNOPSIS
        Whether the Server an event names is one the program runs from: Same, Unknown,
        Different - or empty where no Server is named on one side or the other. Pure.
    .DESCRIPTION
        Same when it is any of the program's Servers, Unknown when it may be one of them,
        and Different only when Compare-ServerName tells it apart from every one of them.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyCollection()][string[]]$Server = @(),
        [AllowNull()][AllowEmptyString()][string]$Named
    )

    $servers = @($Server | Where-Object { $_ })
    $name    = "$Named".Trim()
    if (-not $name -or -not $servers.Count) { return '' }

    $answers = @($servers | ForEach-Object { Compare-ServerName -Program $_ -Named $name })
    if ($answers -contains 'Same')    { return 'Same' }
    if ($answers -contains 'Unknown') { return 'Unknown' }
    'Different'
}

function Select-IncidentDisruption {
    <#
    .SYNOPSIS
        The network disruptions that are held against one incident. Pure.
    .DESCRIPTION
        One of the kinds that count, no further after the incident than the window and no
        further before it than the window before, and not about somebody else's Server: a
        disruption that names a Server other than the program's is left out, one that
        names the program's or none is not. A link that went down names no Server and took
        every one of them with it.
 
        A program that runs from no Server is held against every disruption in the window.
 
        Left out is only what Compare-ServerName calls Different. A disruption to a Server
        that may or may not be the program's - the drive is mapped to a namespace or to an
        address - is held against the incident, and says so: each row returned is a copy
        that carries Attribution, which is Same, Unknown, or empty where no Server was
        named on one side or the other. It carries Offset as well: the seconds it lay from
        the incident, negative before it.
    .PARAMETER Server
        The Servers the program runs from. Empty for a local program, and for one on a
        Server nobody could name.
    .PARAMETER WindowSeconds
        How long after the incident a disruption counts.
    .PARAMETER BeforeSeconds
        How long before the incident it counts. Left out, the same as after: one window
        either side, which is what a program known to be local is held against.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Incident,
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [AllowNull()][AllowEmptyCollection()][string[]]$Server = @(),
        [double]$WindowSeconds = $script:StabilityCorrelationWindowSeconds,
        [double]$BeforeSeconds = -1,
        [AllowNull()][AllowEmptyCollection()][string[]]$Kind = @()
    )

    $time = ConvertTo-DataTime (Get-DataProperty $Incident 'Time')
    if ($null -eq $time) { return }

    if ($BeforeSeconds -lt 0) { $BeforeSeconds = $WindowSeconds }

    foreach ($row in @($Disruption)) {
        if ($null -eq $row) { continue }
        if (@($Kind) -notcontains "$(Get-DataProperty $row 'Kind')") { continue }

        $at = ConvertTo-DataTime (Get-DataProperty $row 'Time')
        if ($null -eq $at) { continue }
        $offset = ($at - $time).TotalSeconds
        if ($offset -gt $WindowSeconds -or $offset -lt -$BeforeSeconds) { continue }

        $attribution = Get-ServerAttribution -Server $Server -Named "$(Get-DataProperty $row 'Server')"
        if ($attribution -eq 'Different') { continue }

        [pscustomobject]@{
            Time        = Get-DataProperty $row 'Time'
            Server      = Get-DataProperty $row 'Server'
            Kind        = Get-DataProperty $row 'Kind'
            Provider    = Get-DataProperty $row 'Provider'
            Id          = Get-DataProperty $row 'Id'
            Attribution = $attribution
            Offset      = $offset
        }
    }
}

function Select-IncidentReconnect {
    <#
    .SYNOPSIS
        The reconnects that are held against one incident: a connection to the program's
        Server that came back shortly after it. Pure.
    .DESCRIPTION
        A program on a share whose connection dropped runs on with what it has in memory,
        and dies when it next needs a page of itself from the share. That access is the
        one Windows re-establishes the session on, so the reconnect is logged seconds
        after the crash - and often it is all that is logged near it, the drop lying
        minutes back.
 
        Only after the incident, and no further than the seconds given. A reconnect at or
        before it is not evidence by itself: it says that an earlier drop was repaired.
 
        The Server is matched as Select-IncidentDisruption matches it: the program's, or
        one that may be the program's, and any Server for a program that runs from none
        anybody could name. But a reconnect that names no Server is left out, where a
        disruption that names none is not: a link that went down took every Server with
        it, and a reconnect to nobody says nothing.
 
        Each row returned is a copy that carries Attribution and Offset, as there.
    .PARAMETER Seconds
        How long after the incident a reconnect counts.
    .PARAMETER Kind
        The kinds that are a reconnect.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Incident,
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [AllowNull()][AllowEmptyCollection()][string[]]$Server = @(),
        [double]$Seconds = $script:StabilityCorrelationReconnectSeconds,
        [AllowNull()][AllowEmptyCollection()][string[]]$Kind = @()
    )

    $time = ConvertTo-DataTime (Get-DataProperty $Incident 'Time')
    if ($null -eq $time) { return }

    foreach ($row in @($Disruption)) {
        if ($null -eq $row) { continue }
        if (@($Kind) -notcontains "$(Get-DataProperty $row 'Kind')") { continue }

        $named = "$(Get-DataProperty $row 'Server')".Trim()
        if (-not $named) { continue }

        $at = ConvertTo-DataTime (Get-DataProperty $row 'Time')
        if ($null -eq $at) { continue }
        $offset = ($at - $time).TotalSeconds
        if ($offset -le 0 -or $offset -gt $Seconds) { continue }

        $attribution = Get-ServerAttribution -Server $Server -Named $named
        if ($attribution -eq 'Different') { continue }

        [pscustomobject]@{
            Time        = Get-DataProperty $row 'Time'
            Server      = Get-DataProperty $row 'Server'
            Kind        = Get-DataProperty $row 'Kind'
            Provider    = Get-DataProperty $row 'Provider'
            Id          = Get-DataProperty $row 'Id'
            Attribution = $attribution
            Offset      = $offset
        }
    }
}

function Get-DisruptionCoverage {
    <#
    .SYNOPSIS
        What the gathered disruptions cover: which of the logs that name a Server were not
        read, and from when on the ones that were read can say anything. Pure.
    .DESCRIPTION
        A log is not read when it was refused, and when the NetworkDisruption Check never
        queried it - the Definition of this Check names a log that Check's Definition
        does not. Where that Check did not say which logs it queried, older data, only a
        refusal is known.
 
        Start is the later of the time that Check looked back to and the oldest record the
        newest-starting of those logs still holds: the SMB client's logs are small, and on
        a machine that uses its shares they reach back days where the Application log
        reaches back months. Neither is measured from now - a fixture is judged the same
        next year. $null where the data says neither, and then nothing is known to lie
        before it.
 
        A log that is empty has no oldest record, and sets no Start: whether it is empty
        because nothing happened or because it was cleared yesterday is not in the data.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$NetworkDisruption,
        [AllowNull()][AllowEmptyCollection()][string[]]$ServerLog = @()
    )

    $logs       = @($ServerLog | Where-Object { $_ })
    $unreadable = @((Get-DataCollection $NetworkDisruption 'UnreadableLogs') | ForEach-Object { "$_".Trim() })
    $refused    = @($logs | Where-Object { $unreadable -contains $_ })

    $unscanned = @()
    if (Test-DataCarried -InputObject $NetworkDisruption -Name 'ScannedLogs') {
        $scanned   = @((Get-DataCollection $NetworkDisruption 'ScannedLogs') | ForEach-Object { "$_".Trim() })
        $unscanned = @($logs | Where-Object { $unreadable -notcontains $_ -and $scanned -notcontains $_ })
    }

    $start = ConvertTo-DataTime (Get-DataProperty $NetworkDisruption 'Since')
    foreach ($row in (Get-DataCollection $NetworkDisruption 'LogCoverage')) {
        $log = "$(Get-DataProperty $row 'Log')".Trim()
        if (-not $log -or $logs -notcontains $log -or $refused -contains $log -or $unscanned -contains $log) { continue }

        $oldest = ConvertTo-DataTime (Get-DataProperty $row 'Oldest')
        if ($null -ne $oldest -and ($null -eq $start -or $oldest -gt $start)) { $start = $oldest }
    }

    [pscustomobject]@{
        Refused   = [string[]]$refused
        Unscanned = [string[]]$unscanned
        Start     = $start
    }
}

function Test-CorrelationWeight {
    <#
    .SYNOPSIS
        Whether the incidents that coincided with a network disruption are enough to hold
        against the network. Pure.
    .DESCRIPTION
        For a program on the network: as many as the Check Definition asks for, one by
        default. Such a program dies of a single lost connection.
 
        For a program known to be local that is not enough. A service that crashes
        twenty-eight times in a month will crash once within two minutes of a Wi-Fi
        disconnect by chance, and that one coincidence is not a reason to send a Technician
        to the network first. There the incidents that coincided must also be a share of
        those that could be looked at: half by default, and a Check Definition parameter.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [double]$Correlated,
        [double]$Total,
        [bool]$KnownLocal,
        [hashtable]$Parameters = @{}
    )

    $needed = Get-NumberParameter $Parameters 'CorrelatedIncidents' 1
    if ($Correlated -le 0 -or $Correlated -lt $needed) { return $false }
    if (-not $KnownLocal) { return $true }

    $percent = Get-NumberParameter $Parameters 'LocalCorrelationPercent' 50
    ($Correlated * 100) -ge ($percent * $Total)
}

function Get-ProgramCorrelation {
    <#
    .SYNOPSIS
        How many of a program's incidents coincided with a network disruption. Pure.
    .DESCRIPTION
        An incident coincides when a disruption that counts lies in its window, or a
        reconnect to the program's Server follows it shortly. The window reaches further
        back than forward for a program that is not known to be local: its connection
        drops, and it dies minutes later. See Get-CorrelationSetting.
 
        A program known to be local is held against the machine losing its network, in
        one window either side, and against no reconnect: that a connection to a Server
        came back says nothing about a program that asks no Server for anything.
 
        Three answers, and State says which:
 
          Correlated some incidents had one
          NotCorrelated disruptions were gathered, they cover the incidents, and none had
                         one. This clears the network, which is worth as much as the other
                         answer - and is said only where it is true.
          NotAssessed nobody could look, and Reason says why not:
                           NotGathered no disruptions were gathered
                           LogsUnreadable a log that names a Server was refused, and
                                           nothing that could be read matched
                           LogsNotScanned a log that names a Server was never queried,
                                           and nothing that was matched
                           NotCovered every incident lies before the logs begin
                           Untimed no incident has a time to hold anything against
                         Not the same as the answer before it, and never to be read as it.
 
        One log refused is enough: a disruption to the Server is written to one of them,
        and nothing says to which. With a log unread and something matching all the same -
        the link went down, which a Run without admin rights can read - the incidents are
        counted, and LogsUnread says that more may have been missed.
 
        Total is the number of incidents that could be held against anything, and the
        number every count is "of". An incident whose window the logs do not reach back to
        the start of, or without a time, is not among them unless something was found for
        it all the same: Before and Untimed count those, and Incidents counts all. It is
        the start of the window that must be covered and not the incident: a connection
        that dropped seven minutes before a crash is otherwise in a part of the log that
        is gone, and the crash would read as cleared.
 
        What was found is counted apart, because the Finding says which it was:
        DroppedBefore is the incidents with a disruption at or before them, DroppedAfter
        those with one after them, and Reconnected those with a reconnect after them. An
        incident can be in all three, and is in Correlated once.
 
        Unattributed names the Servers of disruptions and reconnects that were counted
        though nothing says they are the program's: see Compare-ServerName.
 
        Disruptions holds, for each incident in the order handed over, the disruptions
        held against it, as Found, the reconnects after it, as Reconnects, and whether it
        could be held against anything, as Covered.
    .PARAMETER Network
        What Get-ProgramNetwork returned for the program.
    .PARAMETER NetworkDisruption
        This Kind's copy of what the NetworkDisruption Check gathered. May be absent.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowNull()]$Network,
        [AllowNull()]$NetworkDisruption,
        [hashtable]$Parameters = @{}
    )

    $setting   = Get-CorrelationSetting -Parameters $Parameters
    $incidents = @($Incident | Where-Object { $null -ne $_ })
    $gathered  = [bool](Get-DataProperty $NetworkDisruption 'Gathered')
    $rows      = Get-DataCollection $NetworkDisruption 'Disruptions'

    $onNetwork = [bool](Get-DataProperty $Network 'OnNetwork')
    $servers   = @()
    if ($onNetwork) { $servers = (Get-DataCollection $Network 'Servers') }

    $knownLocal = [bool](Get-DataProperty $Network 'KnownLocal')

    # Older data carries no LocalKinds; then every kind counts, as it did.
    $countedKinds = $setting.Kinds
    $localKinds   = (Get-DataCollection $setting 'LocalKinds')
    if ($knownLocal -and $localKinds.Count) { $countedKinds = $localKinds }

    # A program that is not known to be local is looked at from further back, and for a
    # reconnect after the crash. A local one is held against one window either side, and
    # against no reconnect.
    $windowAfter      = $setting.WindowSeconds
    $windowBefore     = $setting.BeforeSeconds
    $reconnectSeconds = $setting.ReconnectSeconds
    $reconnectKinds   = $setting.ReconnectKinds
    if ($knownLocal) { $windowBefore = $windowAfter; $reconnectKinds = @() }

    $coverage   = Get-DisruptionCoverage -NetworkDisruption $NetworkDisruption -ServerLog $setting.ServerLogs
    $logsUnread = [bool]($coverage.Refused.Count -or $coverage.Unscanned.Count)

    $correlated    = 0
    $named         = 0
    $names         = @()
    $unattributed  = @()
    $before        = 0
    $untimed       = 0
    $droppedBefore = 0
    $droppedAfter  = 0
    $droppedOnlyAfter = 0
    $reconnected   = 0
    # How long after the connection dropped each crash came, in seconds, and for how many
    # of them it was back before the crash. Of a program on the network only.
    $delays        = [System.Collections.Generic.List[double]]::new()
    $restored      = 0
    $perIncident   = [System.Collections.Generic.List[psobject]]::new()
    foreach ($row in $incidents) {
        $found      = @()
        $reconnects = @()
        if ($gathered) {
            $found = @(Select-IncidentDisruption -Incident $row -Disruption $rows -Server $servers `
                -WindowSeconds $windowAfter -BeforeSeconds $windowBefore -Kind $countedKinds)
            if (@($reconnectKinds).Count) {
                $reconnects = @(Select-IncidentReconnect -Incident $row -Disruption $rows -Server $servers `
                    -Seconds $reconnectSeconds -Kind $reconnectKinds)
            }
        }
        $coincides = [bool]($found.Count -or $reconnects.Count)

        # An incident nothing was found for has only been cleared if somebody could have
        # found something: it has a time, and the logs reach back to the start of its
        # window - not merely to the incident, which says nothing of the minutes before.
        $covered = $true
        if ($gathered -and -not $coincides) {
            $time = ConvertTo-DataTime (Get-DataProperty $row 'Time')
            if ($null -eq $time) { $covered = $false; $untimed++ }
            elseif ($null -ne $coverage.Start -and $time.AddSeconds(-$windowBefore) -lt $coverage.Start) { $covered = $false; $before++ }
        }

        $perIncident.Add([pscustomobject]@{ Found = $found; Reconnects = $reconnects; Covered = $covered })
        if (-not $coincides) { continue }

        $correlated++
        $earlier = @($found | Where-Object { $_.Offset -le 0 }).Count
        $later   = @($found | Where-Object { $_.Offset -gt 0 }).Count
        if ($earlier) { $droppedBefore++ }
        if ($later)   { $droppedAfter++ }
        if ($later -and -not $earlier) { $droppedOnlyAfter++ }
        if ($reconnects.Count) { $reconnected++ }

        # From the drop nearest before the crash. Whether the connection came back in
        # between is what a Technician does not expect to read: it did, as a rule, within
        # a second, and the program died all the same.
        if ($earlier -and $onNetwork) {
            $nearest = @($found | Where-Object { $_.Offset -le 0 } | Sort-Object Offset -Descending)[0]
            $delay   = -[double]$nearest.Offset
            $delays.Add($delay)
            if (@($setting.ReconnectKinds).Count) {
                $back = @(Select-IncidentDisruption -Incident $row -Disruption $rows -Server $servers `
                    -WindowSeconds 0 -BeforeSeconds $delay -Kind $setting.ReconnectKinds | Where-Object { "$($_.Server)".Trim() })
                if ($back.Count) { $restored++ }
            }
        }

        $here = @()
        foreach ($disruption in (@($found) + @($reconnects))) {
            $name = "$($disruption.Server)".Trim().ToUpperInvariant()
            if (-not $name) { continue }
            if ($here -notcontains $name) { $here += $name }
            if ($disruption.Attribution -eq 'Unknown' -and $unattributed -notcontains $name) { $unattributed += $name }
        }
        if ($here.Count) { $named++ }
        foreach ($name in $here) { if ($names -notcontains $name) { $names += $name } }
    }
    if ($names.Count -gt 1)        { $names = @($names | Sort-Object) }
    if ($unattributed.Count -gt 1) { $unattributed = @($unattributed | Sort-Object) }

    $total = $incidents.Count - $before - $untimed

    $state  = 'Correlated'
    $reason = ''
    if (-not $gathered) { $state = 'NotAssessed'; $reason = 'NotGathered' }
    elseif (-not $correlated) {
        if ($coverage.Refused.Count)                { $state = 'NotAssessed'; $reason = 'LogsUnreadable' }
        elseif ($coverage.Unscanned.Count)          { $state = 'NotAssessed'; $reason = 'LogsNotScanned' }
        elseif ($incidents.Count -and $total -le 0) {
            $state  = 'NotAssessed'
            $reason = 'Untimed'
            if ($before) { $reason = 'NotCovered' }
        }
        else { $state = 'NotCorrelated' }
    }

    [pscustomobject]@{
        State          = $state
        Reason         = $reason
        # The incidents that could be held against anything: what every count is "of".
        Total          = $total
        Incidents      = $incidents.Count
        Correlated     = $correlated
        # What it was that coincided, each counted in incidents: a disruption at or before
        # the incident, one after it, one after it and none before, a reconnect after it.
        DroppedBefore    = $droppedBefore
        DroppedAfter     = $droppedAfter
        DroppedOnlyAfter = $droppedOnlyAfter
        Reconnected      = $reconnected
        # Of a program on the network: for how many incidents the time from the drop to
        # the crash is known, the shortest, the longest and the middle one of those times
        # in seconds, and for how many the connection was back before the crash.
        Delayed            = $delays.Count
        DelayMinSeconds    = $(if ($delays.Count) { ($delays | Measure-Object -Minimum).Minimum } else { $null })
        DelayMaxSeconds    = $(if ($delays.Count) { ($delays | Measure-Object -Maximum).Maximum } else { $null })
        DelayMedianSeconds = $(if ($delays.Count) { @($delays | Sort-Object)[[int][math]::Floor(($delays.Count - 1) / 2)] } else { $null })
        RestoredBefore     = $restored
        # How many of the correlated incidents had a disruption or a reconnect that named
        # a Server.
        Named          = $named
        Servers        = [string[]]$names
        # The Servers among them that nothing says are the program's, and the program's.
        Unattributed   = [string[]]$unattributed
        ProgramServers = [string[]]@($servers | ForEach-Object { "$_".Trim().ToUpperInvariant() } | Where-Object { $_ })
        OnNetwork      = $onNetwork
        KnownLocal     = $knownLocal
        # Whether what coincided is enough to hold against the network.
        Weighs         = [bool]($gathered -and (Test-CorrelationWeight -Correlated $correlated -Total $total `
                             -KnownLocal $knownLocal -Parameters $Parameters))
        LogsUnread     = [bool]($gathered -and $logsUnread)
        RefusedLogs    = [string[]]$coverage.Refused
        UnscannedLogs  = [string[]]$coverage.Unscanned
        # Incidents nothing was found for and nothing could have been: the logs do not
        # reach back to the start of their window, and without a time.
        Before         = $before
        Untimed        = $untimed
        CoverageStart  = $coverage.Start
        # The windows this program was held against: after an incident, before it - the
        # same for a program known to be local - and for a reconnect after it.
        WindowSeconds    = $windowAfter
        BeforeSeconds    = $windowBefore
        ReconnectSeconds = $reconnectSeconds
        Disruptions    = @($perIncident)
    }
}

function Get-CorrelationWindowText {
    <#
    .SYNOPSIS
        A window as a Technician reads it: "2 Minuten", or "2min" in a column heading. Pure.
    .DESCRIPTION
        With -BeforeSeconds, and where that is another number than -Seconds, both windows:
        "10 Minuten vor bis 2 Minuten nach dem Absturz". Where the two are the same it is
        one window either side, and is named as one.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([double]$Seconds, [switch]$Short, [double]$BeforeSeconds = -1)

    if ($BeforeSeconds -ge 0 -and [int][math]::Round($BeforeSeconds) -ne [int][math]::Round($Seconds)) {
        return (Get-Text 'Value.Stability.Window.Asymmetric' (Get-CorrelationWindowText -Seconds $BeforeSeconds) (
            Get-CorrelationWindowText -Seconds $Seconds))
    }

    $whole = [int][math]::Round($Seconds)
    if ($whole -ge 120 -and $whole % 60 -eq 0) {
        if ($Short) { return (Get-Text 'Value.Stability.Window.ShortMinutes' ($whole / 60)) }
        return (Get-Text 'Value.Stability.Window.Minutes' ($whole / 60))
    }
    if ($Short) { return (Get-Text 'Value.Stability.Window.ShortSeconds' $whole) }
    Get-Text 'Value.Stability.Window.Seconds' $whole
}

function Get-DisruptionColumnHeading {
    <#
    .SYNOPSIS
        The heading of the Section's column of disruptions, which names the window they
        were looked for in: one where it is the same either side of a crash, both where
        it is not. Pure.
    .DESCRIPTION
        The windows are those of a program that is not known to be local. One that is
        known to be local stands in the same table and is held against the narrower of
        the two on both sides.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([double]$Seconds, [double]$BeforeSeconds)

    $after = Get-CorrelationWindowText -Seconds $Seconds -Short
    if ([int][math]::Round($BeforeSeconds) -eq [int][math]::Round($Seconds)) {
        return (Get-Text 'Column.Stability.Incident.Disruption' $after)
    }
    Get-Text 'Column.Stability.Incident.DisruptionAsymmetric' (Get-CorrelationWindowText -Seconds $BeforeSeconds -Short) $after
}

function Get-ProgramCorrelationLine {
    <#
    .SYNOPSIS
        What a program's Finding says about its crashes and the network. Pure.
    .DESCRIPTION
        "2 von 8 Vorfaellen mit SMB-Stoerung zu FS01 innerhalb von 10 Minuten vor bis 2
        Minuten nach dem Absturz" for a program that runs from FS01. Of a local program the
        same is said neutrally, and with the one window it is held against: its crashes
        coincided with a disruption, and nothing says the disruption reached it.
 
        Where a reconnect after the crash is among what was found, the line says what was
        found and in how many incidents: "6 von 8 Vorfaellen mit Netzstoerung zu FS01:
        Verbindung bis zu 10 Minuten vor dem Absturz abgebrochen (4), innerhalb von 2
        Minuten nach dem Absturz wiederhergestellt (6)". An incident whose connection
        dropped only after the crash is named as that; one where it dropped before as well
        is counted under "before", once.
 
        One line as a rule. A second and a third say how many incidents are not among
        those counted, and why: they lie before the logs begin, or have no time. The lines
        are written to the pipeline one by one: collect them with @( ).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Correlation)

    # Nothing about the network of a program the network does not concern.
    if ([bool](Get-DataProperty $Correlation 'SetAside')) { return }

    $before  = ConvertTo-Number (Get-DataProperty $Correlation 'Before')
    $untimed = ConvertTo-Number (Get-DataProperty $Correlation 'Untimed')
    $start   = Get-DataProperty $Correlation 'CoverageStart'
    $unread  = @((Get-DataCollection $Correlation 'RefusedLogs')) + @((Get-DataCollection $Correlation 'UnscannedLogs'))

    if ($Correlation.State -eq 'NotAssessed') {
        switch ($Correlation.Reason) {
            'LogsUnreadable' {
                $line = Get-Text 'Value.Stability.Program.CorrelationLogsUnreadable'
                if ($unread.Count) { $line = Get-Text 'Value.Stability.Program.LogsNamed' $line ($unread -join ', ') }
            }
            'LogsNotScanned' { $line = Get-Text 'Value.Stability.Program.CorrelationLogsNotScanned' ($unread -join ', ') }
            'NotCovered'     { $line = Get-Text 'Value.Stability.Program.CorrelationNotCovered' $before $start; $before = 0 }
            'Untimed'        { $line = Get-Text 'Value.Stability.Program.CorrelationUntimed' $untimed; $untimed = 0 }
            default          { $line = Get-Text 'Value.Stability.Program.CorrelationNotGathered' }
        }
    }
    elseif ($Correlation.State -eq 'NotCorrelated') {
        $line = Get-Text 'Value.Stability.Program.NotCorrelated' $Correlation.Total
    }
    else {
        # A correlation from before there were two windows carries one, for either side.
        $after   = ConvertTo-Number $Correlation.WindowSeconds
        $earlier = ConvertTo-Number (Get-DataProperty $Correlation 'BeforeSeconds')
        if ($null -eq $earlier) { $earlier = $after }
        $window  = Get-CorrelationWindowText -Seconds $after -BeforeSeconds $earlier
        $servers = @($Correlation.Servers) -join ', '
        $count   = $Correlation.Correlated
        $total   = $Correlation.Total

        $reconnected = ConvertTo-Number (Get-DataProperty $Correlation 'Reconnected')
        if ($reconnected -gt 0) {
            # What was found, each with the number of incidents it was found for. A
            # reconnect names its Server, so there is always one to name.
            $parts   = @()
            $dropped = ConvertTo-Number (Get-DataProperty $Correlation 'DroppedBefore')
            $later   = ConvertTo-Number (Get-DataProperty $Correlation 'DroppedOnlyAfter')
            if ($dropped -gt 0) { $parts += Get-Text 'Value.Stability.Program.EvidenceDroppedBefore' (Get-CorrelationWindowText -Seconds $earlier) $dropped }
            if ($later -gt 0)   { $parts += Get-Text 'Value.Stability.Program.EvidenceDroppedAfter' (Get-CorrelationWindowText -Seconds $after) $later }
            $parts += Get-Text 'Value.Stability.Program.EvidenceReconnected' (
                Get-CorrelationWindowText -Seconds (ConvertTo-Number (Get-DataProperty $Correlation 'ReconnectSeconds'))) $reconnected
            $what = $parts -join ', '

            if (-not $Correlation.OnNetwork)       { $line = Get-Text 'Value.Stability.Program.EvidenceNeutral' $count $total $servers $what }
            elseif ($Correlation.Named -eq $count) { $line = Get-Text 'Value.Stability.Program.EvidenceServer' $count $total $servers $what }
            else { $line = Get-Text 'Value.Stability.Program.EvidenceMixed' $count $total $Correlation.Named $servers $what }
        }
        elseif (-not $Correlation.OnNetwork) {
            if ($servers) { $line = Get-Text 'Value.Stability.Program.CorrelatedNeutralServer' $count $total $window $servers }
            else          { $line = Get-Text 'Value.Stability.Program.CorrelatedNeutral' $count $total $window }
        }
        elseif (-not $Correlation.Named)              { $line = Get-Text 'Value.Stability.Program.CorrelatedLink' $count $total $window }
        elseif ($Correlation.Named -eq $count)        { $line = Get-Text 'Value.Stability.Program.CorrelatedServer' $count $total $servers $window }
        else { $line = Get-Text 'Value.Stability.Program.CorrelatedMixed' $count $total $window $Correlation.Named $servers }

        # Counted, and said to be counted without knowing: the drive is mapped to a
        # namespace or an address, and the event names a Server.
        $unattributed = Get-DataCollection $Correlation 'Unattributed'
        if ($unattributed.Count) {
            $line = Get-Text 'Value.Stability.Program.ServerUnattributed' $line ($unattributed -join ', ') (
                (Get-DataCollection $Correlation 'ProgramServers') -join ', ')
        }

        if ($Correlation.LogsUnread) { $line = Get-Text 'Value.Stability.Program.CorrelatedPartial' $line }
    }

    $line
    if ($before -gt 0)  { Get-Text 'Value.Stability.Program.IncidentsBeforeCoverage' $before $start }
    if ($untimed -gt 0) { Get-Text 'Value.Stability.Program.IncidentsUntimed' $untimed }

    # How long after the drop the crashes came: the sentence that makes "the network was
    # fine when it crashed" and "it is the network" both true.
    $delayed = ConvertTo-Number (Get-DataProperty $Correlation 'Delayed')
    if ($Correlation.State -eq 'Correlated' -and $delayed -gt 0) {
        $shortest = Get-DelayText -Seconds (ConvertTo-Number (Get-DataProperty $Correlation 'DelayMinSeconds'))
        $longest  = Get-DelayText -Seconds (ConvertTo-Number (Get-DataProperty $Correlation 'DelayMaxSeconds'))
        if ($delayed -eq 1 -or $shortest -eq $longest) { Get-Text 'Value.Stability.Program.DelayOne' $shortest }
        else {
            Get-Text 'Value.Stability.Program.Delay' $shortest $longest (
                Get-DelayText -Seconds (ConvertTo-Number (Get-DataProperty $Correlation 'DelayMedianSeconds')))
        }
        $back = ConvertTo-Number (Get-DataProperty $Correlation 'RestoredBefore')
        if ($back -gt 0) { Get-Text 'Value.Stability.Program.RestoredBefore' $back $delayed }
    }
}

function Get-DelayText {
    <#
    .SYNOPSIS
        A length of time as a Technician reads it: "45 Sekunden" below two minutes, whole
        minutes from there. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Seconds)

    $number = ConvertTo-Number $Seconds
    if ($null -eq $number -or $number -lt 0) { $number = 0 }
    if ($number -lt 120) { return (Get-Text 'Value.Stability.Window.Seconds' ([int][math]::Round($number))) }
    Get-Text 'Value.Stability.Window.Minutes' ([int][math]::Round($number / 60))
}

function Get-ProgramCorrelationHint {
    <#
    .SYNOPSIS
        What to do about what Get-ProgramCorrelationLine says. Nothing when the network is
        cleared. Pure.
    .DESCRIPTION
        To look at the network is said where the coincidences are worth it: of a program
        known to be local, only when they weigh. See Test-CorrelationWeight.
 
        Where a reconnect after the crash is among what was found, a Hint ahead of that one
        says where to look: in the minutes before the crash and not in its second. Why
        there is the Meaning. See Get-ProgramCorrelationMeaning.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Correlation, [AllowNull()]$Situation)

    if ([bool](Get-DataProperty $Correlation 'SetAside')) { return }

    $reason = "$(Get-DataProperty $Correlation 'Reason')"

    # What a repeat Run with admin rights would add, wherever something was refused.
    # Not gathered at all is not a refused prompt alone: the Check may have been left out
    # of this Run, and "confirm the prompt" is then advice for something else.
    if ($reason -eq 'NotGathered') { Get-Text 'Hint.Stability.CorrelationNotGathered' }
    elseif ($reason -eq 'LogsUnreadable' -or (Get-DataCollection $Correlation 'RefusedLogs').Count) {
        Get-Text 'Hint.Stability.CorrelationRerunWithAdmin'
    }
    # Admin rights do not help where nobody asked for the log, or the log is too short.
    if ((Get-DataCollection $Correlation 'UnscannedLogs').Count) { Get-Text 'Hint.Stability.CorrelationLogsNotScanned' }
    if ($reason -eq 'NotCovered') { Get-Text 'Hint.Stability.CorrelationNotCovered' }

    if (Test-CorrelationWorthALook -Correlation $Correlation) {
        if ((ConvertTo-Number (Get-DataProperty $Correlation 'Reconnected')) -gt 0) { Get-Text 'Hint.Stability.ProgramReconnectedAfter' }
        Get-StabilityNetworkHint -Kind Coincided -Situation $Situation
        # The other way out: a program that does not run from the share does not die of
        # the share going away for a second.
        if ((ConvertTo-Number (Get-DataProperty $Correlation 'Delayed')) -gt 0) { Get-Text 'Hint.Stability.ProgramRunLocal' }
    }
}

function Get-StabilityLinkText {
    <#
    .SYNOPSIS
        What there is to check on this machine's side of its connection, to say in a
        Hint - or nothing where it is not known what the machine is connected by. Pure.
    .DESCRIPTION
        Nothing, and not "cable, docking station, Wi-Fi and switch": where nothing is
        known the Hints keep the wording they had, which names them all in a sentence of
        its own. The wording for a machine that may be docked ends on a comma, for the
        middle of a sentence, and is taken without it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Situation)

    $known = Select-SituationText -Situation $Situation -Fact Connection -Text @{
        Wired = 'yes'; Wireless = 'yes'; Vpn = 'yes'; Unknown = ''
    }
    if (-not $known) { return '' }
    (Get-SituationLinkText -Situation $Situation).Trim().TrimEnd(',').Trim()
}

function Get-StabilityNetworkHint {
    <#
    .SYNOPSIS
        What to do where a program's crashes point at the network: the cause, a suspicion,
        or a disruption that coincided. Naming what this machine is connected by, where
        that is known. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][ValidateSet('Cause', 'Suspected', 'Coincided')][string]$Kind,
        [AllowNull()]$Situation
    )

    $link = Get-StabilityLinkText -Situation $Situation
    switch ($Kind) {
        'Cause' {
            if ($link) { return (Get-Text 'Hint.Stability.VerdictNetworkCause.Link') -f $link }
            return Get-Text 'Hint.Stability.VerdictNetworkCause'
        }
        'Suspected' {
            if ($link) { return (Get-Text 'Hint.Stability.VerdictNetworkSuspected.Link') -f $link }
            return Get-Text 'Hint.Stability.VerdictNetworkSuspected'
        }
    }
    if ($link) { return (Get-Text 'Hint.Stability.ProgramCorrelated.Link') -f $link }
    Get-Text 'Hint.Stability.ProgramCorrelated'
}

function Get-StabilityUsbAdapterHint {
    <#
    .SYNOPSIS
        What to do about a network adapter on USB: with the docking station, unless the
        machine is known not to be in one. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Situation)

    Select-SituationText -Situation $Situation -Fact Docked -Text @{
        No      = Get-Text 'Hint.Stability.UsbAdapter.NoDock'
        Unknown = Get-Text 'Hint.Stability.UsbAdapter'
    }
}

function Test-CorrelationWorthALook {
    <#
    .SYNOPSIS
        Whether what coincided with a program's crashes is worth a look at the network: of
        a program known to be local, only when it weighs. Pure. See Test-CorrelationWeight.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$Correlation)

    "$(Get-DataProperty $Correlation 'State')" -eq 'Correlated' -and
        ([bool](Get-DataProperty $Correlation 'Weighs') -or -not [bool](Get-DataProperty $Correlation 'KnownLocal'))
}

function Get-ProgramCorrelationMeaning {
    <#
    .SYNOPSIS
        What Get-ProgramCorrelationLine says means, where the line does not say so itself.
        Nothing when it does. Pure.
    .DESCRIPTION
        Two things are not in the line. Why a log was not held against the crashes when
        nobody asked for it: two Check Definitions that disagree. And what a reconnect
        after a crash says: the connection had dropped before, and the program died when
        it next read from the share.
 
        Each is said where Get-ProgramCorrelationHint says what to do about it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Correlation)

    if ([bool](Get-DataProperty $Correlation 'SetAside')) { return }

    if ((Get-DataCollection $Correlation 'UnscannedLogs').Count) { Get-Text 'Meaning.Stability.CorrelationLogsNotScanned' }

    if (-not (Test-CorrelationWorthALook -Correlation $Correlation)) { return }

    # Where the time from the drop to the crash is known, that is the whole story and is
    # told as one; a reconnect after the crash is then one more sign of it and needs no
    # sentence of its own.
    if ((ConvertTo-Number (Get-DataProperty $Correlation 'Delayed')) -gt 0) {
        Get-Text 'Meaning.Stability.ProgramDiesLater'
        if ((ConvertTo-Number (Get-DataProperty $Correlation 'RestoredBefore')) -gt 0) { Get-Text 'Meaning.Stability.ProgramRestoredNoHelp' }
    }
    elseif ((ConvertTo-Number (Get-DataProperty $Correlation 'Reconnected')) -gt 0) {
        Get-Text 'Meaning.Stability.ProgramReconnectedAfter'
    }
}

function Get-IncidentDisruptionText {
    <#
    .SYNOPSIS
        The disruptions held against one incident, and the reconnects after it, as the
        cell the Section shows. Pure.
    .DESCRIPTION
        The kind as the list of disruptions names it, the Server, and how many seconds
        before or after the crash. "None" only where somebody looked, and could have seen.
 
        In the order they happened, and what was logged more than once in a second once: a
        connection that drops is logged as the connection, the session and the share. The
        first four are shown - or the first three and the first reconnect after the crash,
        where that would otherwise be among the ones left out: it is the reconnect that
        ties the drop minutes before to the crash.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]$Incident,
        [AllowNull()][AllowEmptyCollection()]$Disruption,
        [Parameter(Mandatory)]$Correlation,

        # Whether the incident could be held against anything. See Get-ProgramCorrelation.
        [bool]$Covered = $true,

        # The reconnects held against the incident. See Select-IncidentReconnect.
        [AllowNull()][AllowEmptyCollection()]$Reconnect
    )

    $reconnects = @($Reconnect | Where-Object { $null -ne $_ })
    $found      = @($Disruption | Where-Object { $null -ne $_ }) + $reconnects
    if (-not $found.Count) {
        if ($Correlation.State -eq 'NotAssessed' -or $Correlation.LogsUnread -or -not $Covered) {
            return (Get-Text 'Value.Stability.Incident.DisruptionNotAssessed')
        }
        return (Get-Text 'Value.Stability.Incident.DisruptionNone')
    }

    $time = ConvertTo-DataTime (Get-DataProperty $Incident 'Time')
    $seen = @{}
    $rows = @(for ($index = 0; $index -lt $found.Count; $index++) {
        $row = $found[$index]
        # The token is data; what the Report shows is the German written for it.
        $label = ('{0} {1}' -f (Get-NetworkDisruptionKindText -Kind "$(Get-DataProperty $row 'Kind')"),
                  "$(Get-DataProperty $row 'Server')".Trim().ToUpperInvariant()).Trim()
        if ("$(Get-DataProperty $row 'Attribution')" -eq 'Unknown') {
            $label = Get-Text 'Value.Stability.Incident.DisruptionUnattributed' $label
        }
        $offset = [int][math]::Round(((ConvertTo-DataTime (Get-DataProperty $row 'Time')) - $time).TotalSeconds)

        # Once, what was logged more than once in the same second.
        $key = '{0}|{1}' -f $offset, $label
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true

        [pscustomobject]@{
            Offset    = $offset
            Label     = $label
            # The reconnects stand behind the disruptions in $found.
            Reconnect = [bool]($index -ge ($found.Count - $reconnects.Count))
        }
    })
    # In the order they happened; two in the same second by what they were.
    if ($rows.Count -gt 1) { $rows = @($rows | Sort-Object @{ Expression = { $_.Offset } }, @{ Expression = { $_.Label } }) }

    # The first few: a Server that drops logs a handful of events each time. And the
    # reconnect after the crash, when the first few are all from the minutes before it.
    $shown = @($rows | Select-Object -First 4)
    if ($rows.Count -gt 4 -and -not @($shown | Where-Object { $_.Reconnect }).Count) {
        $back = @($rows | Where-Object { $_.Reconnect } | Select-Object -First 1)
        if ($back.Count) { $shown = @($rows | Select-Object -First 3) + $back }
    }
    $text  = @($shown | ForEach-Object { Get-Text 'Value.Stability.Incident.Disruption' $_.Label $_.Offset }) -join ', '
    if ($shown.Count -lt $rows.Count) { $text = Get-Text 'Value.Stability.ScatteredMore' $text ($rows.Count - $shown.Count) }
    $text
}

function Get-SectionCorrelationParameter {
    <#
    .SYNOPSIS
        The window and the kinds of disruption the Gatherer carried, as the parameters the
        Judge was given. Pure.
    .DESCRIPTION
        A Section is built from data alone. What the Check Definition said about holding
        crashes against the network travels in the data for that reason, and where it does
        not - older data - the defaults apply, as they did to the Finding.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([AllowNull()]$Data)

    $carried    = Get-DataProperty $Data 'Correlation'
    $parameters = @{}

    $window = ConvertTo-Number (Get-DataProperty $carried 'WindowSeconds')
    if ($null -ne $window) { $parameters['CorrelationWindowSeconds'] = $window }

    # Data from before a crash was looked at from further back, and for a reconnect after
    # it, carries neither: then the Kind's own apply, as they do to the Finding.
    $before = ConvertTo-Number (Get-DataProperty $carried 'BeforeSeconds')
    if ($null -ne $before) { $parameters['CorrelationBeforeSeconds'] = $before }

    $reconnect = ConvertTo-Number (Get-DataProperty $carried 'ReconnectSeconds')
    if ($null -ne $reconnect) { $parameters['CorrelationReconnectSeconds'] = $reconnect }

    $kinds = Get-DataCollection $carried 'Kinds'
    if ($kinds.Count) { $parameters['CorrelationKinds'] = $kinds }

    $reconnectKinds = Get-DataCollection $carried 'ReconnectKinds'
    if ($reconnectKinds.Count) { $parameters['ReconnectKinds'] = $reconnectKinds }

    $logs = Get-DataCollection $carried 'ServerLogs'
    if ($logs.Count) { $parameters['ServerEventLogs'] = $logs }

    $parameters
}

function Get-IncidentDisruptionColumn {
    <#
    .SYNOPSIS
        For every incident, in the order handed over, what the Section shows of the
        network disruptions around it. Pure.
    .DESCRIPTION
        Held against the network program by program, exactly as the Findings are: a
        disruption to a Server stands beside the crash of a program that runs from it,
        and not beside that of one that runs from another.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Incident,
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    $incidents  = @($Incident | Where-Object { $null -ne $_ })
    $dependency = Get-DataProperty $Data 'NetworkDependency'
    $disruption = Get-DataProperty $Data 'NetworkDisruption'

    $byProgram = @{}
    for ($index = 0; $index -lt $incidents.Count; $index++) {
        $key = ConvertTo-ProgramKey (Get-DataProperty $incidents[$index] 'App')
        if (-not $byProgram.ContainsKey($key)) { $byProgram[$key] = @() }
        $byProgram[$key] += $index
    }

    $cells = @('') * $incidents.Count
    foreach ($key in @($byProgram.Keys)) {
        $indexes = @($byProgram[$key])
        $rows    = @($indexes | ForEach-Object { $incidents[$_] })

        $network     = Get-ProgramNetwork -App $key -Incident $rows -NetworkDependency $dependency
        $correlation = Get-ProgramCorrelation -Incident $rows -Network $network -NetworkDisruption $disruption -Parameters $Parameters

        for ($position = 0; $position -lt $indexes.Count; $position++) {
            $cells[$indexes[$position]] = Get-IncidentDisruptionText -Incident $rows[$position] `
                -Disruption $correlation.Disruptions[$position].Found -Correlation $correlation `
                -Covered ([bool]$correlation.Disruptions[$position].Covered) `
                -Reconnect $correlation.Disruptions[$position].Reconnects
        }
    }
    , [string[]]$cells
}