# All files / src/utils networkVotingSimilarity.ts

92% Statements 69/75
84.78% Branches 39/46
100% Functions 9/9
100% Lines 61/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239

    5x

    5x

    5x

    91x
    10x

    40x
    40x
    103x
    100x
    100x

    40x

    40x
    100x
    100x
    100x
    91x
    91x
    91x
    91x
    91x
    21x
    21x

    91x
    91x

    4x
    2x
    2x

    21x
    21x
    21x
    20x

    20x
    9x
    9x

    21x
    21x

    26x
    2x

    21x
    21x
    21x
    40x
    40x
    40x
    40x
    40x

    21x

    26x
    26x
    26x

    26x

    26x
    1x

    23x
    23x
    23x

    21x
    21x
    21x

    2x

    2x

     /**
     * @fileoverview Pairwise MEP voting-similarity helper for network OSINT.
     *
     * Fetches DOCEO RCV records once and computes Jaccard-like agreement
     * scores over the set of **decisive** votes (FOR / AGAINST only — abstentions
     * excluded) that two MEPs jointly participated in.
     *
     * `similarity(A, B) = sharedAgreements(A, B) / sharedDecisiveVotes(A, B)`
     *
     * Pairs with fewer than {@link MIN_SHARED_DECISIVE_VOTES} co-participations
     * are skipped to suppress spurious edges with poor statistical support.
     *
     * The helper is intentionally narrow — it does NOT cache the matrix, since
     * each `network_analysis` call may scope to a different MEP subset.
     *
     * Behaviour mirrors `computeCoalitionCohesionFromDoceo`:
     * - bounded by `withTimeoutAndAbort` (default 5 s — larger than the 2 s
     *   cohesion guard because individual-vote XML is heavier)
     * - errors are silently suppressed → the caller falls back to committee edges
     *
     * ISMS Policy: SC-002, AC-003, AU-002. GDPR Article 5(1)(d) — accuracy
     * (Open-data public-figure data, decisive RCVs only).
     *
     * @module utils/networkVotingSimilarity
     * @since 1.4.0
     */

    import { doceoClient } from '../clients/ep/doceoClient.js';
    import type { LatestVotesResponse } from '../clients/ep/doceoClient.js';
    import type { LatestVoteRecord } from '../clients/ep/doceoXmlParser.js';
    import { withTimeoutAndAbort } from './timeout.js';
    import { auditLogger, toErrorMessage } from './auditLogger.js';

    /** Default DOCEO timeout for the network helper (ms). */
    export const NETWORK_VOTING_SIMILARITY_TIMEOUT_MS = 5_000;

    /** Minimum shared decisive votes for a similarity score to be reported. */
    export const MIN_SHARED_DECISIVE_VOTES = 3;

    /** Default page size for the DOCEO fetch (matches coalition cohesion). */
    export const DEFAULT_DOCEO_PAGE_SIZE = 100;

    /**
     * Result of a single similarity computation between MEPs.
     */
    export interface VotingSimilarityEdge {
      /** Stable lexicographic-first MEP id. */
      sourceId: string;
      /** Stable lexicographic-second MEP id. */
      targetId: string;
      /** Agreement share in `[0, 1]`. */
      similarity: number;
      /** Number of decisive votes both MEPs participated in. */
      sharedDecisiveVotes: number;
    }

    /**
     * Result envelope for {@link computeNetworkVotingSimilarityFromDoceo}.
     */
    export interface NetworkVotingSimilarityResult {
      /** Pairs with similarity ≥ `minSimilarity` and ≥ {@link MIN_SHARED_DECISIVE_VOTES} co-participations. */
      edges: VotingSimilarityEdge[];
      /** Number of DOCEO RCV records inspected. */
      rcvVotesInspected: number;
      /** Whether the result was served from DOCEO (always `'DOCEO'` on success). */
      dataSource: 'DOCEO';
    }

    /** Options accepted by {@link computeNetworkVotingSimilarityFromDoceo}. */
    export interface ComputeNetworkVotingSimilarityOptions {
      /** Minimum similarity score (default 0.7). */
      minSimilarity?: number;
      /** Max DOCEO records to fetch (default 100). */
      limit?: number;
      /** Override the default 5 s DOCEO timeout (ms). */
      timeoutMs?: number;
      /** Plenary-week anchor passed through to `doceoClient.getLatestVotes`. */
      weekStart?: string;
    }

    interface PairCounts {
      agreements: number;
      shared: number;
    }

    function makePairKey(a: string, b: string): { key: string; first: string; second: string } {
      if (a < b) return { key: `${a}|${b}`, first: a, second: b };
      return { key: `${b}|${a}`, first: b, second: a };
    }

    /**
     * Extract only decisive MEP votes (FOR / AGAINST) that belong to the supplied
     * subset. Abstentions and out-of-scope ids are excluded.
     */
    function extractDecisiveMeps(
      mepVotes: Record<string, 'FOR' | 'AGAINST' | 'ABSTAIN'>,
      subset: ReadonlySet<string>
    ): { id: string; pos: 'FOR' | 'AGAINST' }[] {
      const out: { id: string; pos: 'FOR' | 'AGAINST' }[] = [];
      for (const [id, pos] of Object.entries(mepVotes)) {
        if (pos === 'ABSTAIN') continue;
        Iif (!subset.has(id)) continue;
        out.push({ id, pos });
      }
      return out;
    }

    function tallyPairs(
      decisive: readonly { id: string; pos: 'FOR' | 'AGAINST' }[],
      pairs: Map<string, PairCounts>
    ): void {
      for (let i = 0; i < decisive.length; i++) {
        const a = decisive[i];
        Iif (a === undefined) continue;
        for (let j = i + 1; j < decisive.length; j++) {
          const b = decisive[j];
          Iif (b === undefined) continue;
          const { key } = makePairKey(a.id, b.id);
          let counts = pairs.get(key);
          if (counts === undefined) {
            counts = { agreements: 0, shared: 0 };
            pairs.set(key, counts);
          }
          counts.shared += 1;
          if (a.pos === b.pos) counts.agreements += 1;
        }
      }
    }

    function compareEdge(a: VotingSimilarityEdge, b: VotingSimilarityEdge): number {
      if (a.sourceId !== b.sourceId) return a.sourceId < b.sourceId ? -1 : 1;
      Iif (a.targetId === b.targetId) return 0;
      return a.targetId < b.targetId ? -1 : 1;
    }

    function buildEdgesFromPairs(
      pairs: Map<string, PairCounts>,
      minSimilarity: number
    ): VotingSimilarityEdge[] {
      const edges: VotingSimilarityEdge[] = [];
      for (const [key, counts] of pairs) {
        if (counts.shared < MIN_SHARED_DECISIVE_VOTES) continue;
        const similarity = counts.agreements / counts.shared;
        // Exclude zero-similarity edges even when minSimilarity=0 to avoid
        // 0-weight edges that cause inconsistencies in weighted algorithms.
        if (similarity <= 0 || similarity < minSimilarity) continue;
        const [sourceId, targetId] = key.split('|') as [string, string];
        edges.push({
          sourceId,
          targetId,
          similarity: Math.round(similarity * 10000) / 10000,
          sharedDecisiveVotes: counts.shared,
        });
      }
      edges.sort(compareEdge);
      return edges;
    }

    function validateDoceoLimit(limit: number): void {
      if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
        throw new RangeError(
          `computeNetworkVotingSimilarityFromDoceo: options.limit must be an integer in [1, 100], received ${String(limit)}`
        );
      }
    }

    function tallyPairsFromResponse(
      response: LatestVotesResponse,
      mepIdSubset: ReadonlySet<string>
    ): { pairs: Map<string, PairCounts>; rcvVotesInspected: number } {
      const pairs = new Map<string, PairCounts>();
      let rcvVotesInspected = 0;
      for (const vote of response.data satisfies readonly LatestVoteRecord[]) {
        Iif (vote.dataSource !== 'RCV') continue;
        Iif (vote.mepVotes === undefined) continue;
        rcvVotesInspected += 1;
        const decisiveMeps = extractDecisiveMeps(vote.mepVotes, mepIdSubset);
        tallyPairs(decisiveMeps, pairs);
      }
      return { pairs, rcvVotesInspected };
    }

    /**
     * Compute pairwise voting-similarity edges for the supplied MEP id set.
     *
     * Only MEP ids in `mepIdSubset` are considered, keeping the O(V²) inner loop
     * bounded by the caller's network size (≤ 900 MEPs).
     *
     * @param mepIdSubset - Set of MEP ids to score (typically the network nodes).
     * @param options - Optional bounds and timeout overrides.
     * @returns Edges with similarity ≥ `minSimilarity` (default 0.7) and
     *   ≥ {@link MIN_SHARED_DECISIVE_VOTES} co-participations, or `null` when
     *   DOCEO is unavailable.
     *
     * @security Errors are audit-logged as `network_voting_similarity.fetch` —
     *   only the inspected count and node count are logged; no PII.
     */
    export async function computeNetworkVotingSimilarityFromDoceo(
      mepIdSubset: ReadonlySet<string>,
      options: ComputeNetworkVotingSimilarityOptions = {}
    ): Promise<NetworkVotingSimilarityResult | null> {
      const minSimilarity = options.minSimilarity ?? 0.7;
      const limit = options.limit ?? DEFAULT_DOCEO_PAGE_SIZE;
      const timeoutMs = options.timeoutMs ?? NETWORK_VOTING_SIMILARITY_TIMEOUT_MS;

      // Validate `limit` *before* the try/catch so a misuse (e.g. caller-supplied
      // 0 or 200) surfaces as a RangeError instead of being swallowed and made
      // indistinguishable from a real DOCEO outage (which returns `null`).
      validateDoceoLimit(limit);

      if (mepIdSubset.size === 0) {
        return { edges: [], rcvVotesInspected: 0, dataSource: 'DOCEO' };
      }

      try {
        const response = await withTimeoutAndAbort(
          (signal) => doceoClient.getLatestVotes({
            includeIndividualVotes: true,
            limit,
            weekStart: options.weekStart,
            abortSignal: signal,
          }),
          timeoutMs,
          'Network voting similarity DOCEO fetch timed out'
        );

        const { pairs, rcvVotesInspected } = tallyPairsFromResponse(response, mepIdSubset);
        const edges = buildEdgesFromPairs(pairs, minSimilarity);
        return { edges, rcvVotesInspected, dataSource: 'DOCEO' };
      } catch (error: unknown) {
        auditLogger.logError(
          'network_voting_similarity.fetch',
          { nodeCount: mepIdSubset.size },
          toErrorMessage(error)
        );
        return null;
      }
    }