# All files / src/utils effectivenessAggregator.ts

98.44% Statements 127/129
94.11% Branches 80/85
100% Functions 19/19
100% Lines 100/100

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
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    407
    408
    409
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    439
    440

    72x
    72x
    70x
    70x
    70x
    46x

    70x
    70x
    17x

    133x

    62x
    59x
    58x
    58x
    105x
    105x

    86x

    9x

    58x
    109x
    58x

    45x
    45x

    45x
    97x

    58x
    5x
    20x

    58x

    481x
    67x
    481x

    240x
    237x

    5x

    36x
    36x
    34x
    34x
    29x
    56x
    29x

    25x

    18x
    18x
    16x
    18x

    15x

    54x
    54x
    54x
    243x

    207x

    36x
    36x
    11x

    54x

    54x
    54x
    6x
    6x
    6x

    54x

    54x
    54x
    243x
    29x
    29x
    5x

    54x

    54x
    54x
    54x
    19x
    18x
    15x
    15x

    54x

    54x
    54x
    6x
    5x
    3x

    54x

    270x

    54x

    54x

    54x
    54x
    54x

    54x

    54x

    54x
    54x
    54x
    54x
    54x

    54x
    54x

    54x

     /**
     * Legislative-effectiveness aggregator.
     *
     * Pure helpers that turn the per-source EP API payloads consumed by
     * `analyze_legislative_effectiveness` into deterministic effectiveness metrics
     * keyed by a subject MEP. Kept side-effect-free so the tool handler can fan
     * out network calls under `Promise.allSettled`, then defer aggregation to a
     * single synchronous step that is easy to unit-test.
     *
     * **Inputs:** raw `Procedure`, `AdoptedText`, `LegislativeDocument`, and
     * `ParliamentaryQuestion` arrays (already filtered to a date window upstream).
     *
     * **Outputs:** {@link LegislativeMetrics} — counts of reports authored,
     * opinions delivered, amendments tabled/adopted, questions asked, and the
     * derived legislative success rate.
     *
     * ISMS Policy: SC-002 (Input Validation), AC-003 (Least Privilege).
     *
     * @module utils/effectivenessAggregator
     */

    import type { Procedure, AdoptedText } from '../types/ep/activities.js';
    import type { LegislativeDocument } from '../types/ep/document.js';
    import type { ParliamentaryQuestion } from '../types/ep/question.js';

    /**
     * Effectiveness metrics computed from real EP data.
     *
     * Each field is a count derived from a single EP API source; the
     * `legislativeSuccessRate` is the only derived ratio.
     */
    export interface LegislativeMetrics {
      /** Procedures where the subject appears as rapporteur. */
      reportsAuthored: number;
      /** Procedures where the subject acts as shadow- or opinion-rapporteur. */
      opinionsDelivered: number;
      /** Plenary-session document items authored by the subject. */
      amendmentsTabled: number;
      /** Amendment-typed document items authored by the subject that reached an ADOPTED status. */
      amendmentsAdopted: number;
      /** Parliamentary questions filed by the subject. */
      questionsAsked: number;
      /**
       * Percentage of subject-attributed procedures that resulted in an
       * adopted text (0-100, two-decimal precision).
       */
      legislativeSuccessRate: number;
    }

    /**
     * Deterministic ordering payload returned alongside the counts so callers
     * (tests, downstream tools) can audit which records contributed.
     *
     * Lists are sorted by stable identifier ascending so two runs over the same
     * input produce identical output regardless of fetch order.
     */
    export interface AggregatedAttributions {
      /** Procedure IDs counted as reports authored, ascending. */
      reportProcedureIds: string[];
      /** Procedure IDs counted as opinions delivered, ascending. */
      opinionProcedureIds: string[];
      /** Document IDs counted as amendments tabled, ascending. */
      amendmentDocumentIds: string[];
      /** Document IDs counted as adopted amendments, ascending. */
      amendmentAdoptedDocumentIds: string[];
      /** Parliamentary question IDs counted, ascending. */
      questionIds: string[];
    }

    /**
     * Combined aggregator result.
     */
    export interface AggregationResult {
      metrics: LegislativeMetrics;
      attributions: AggregatedAttributions;
      /** Number of distinct procedures attributed to the subject. */
      attributedProcedureCount: number;
      /** Number of attributed procedures with a matching adopted text. */
      proceduresWithAdoptedText: number;
    }

    // ---------------------------------------------------------------------------
    // Identifier normalisation
    // ---------------------------------------------------------------------------

    /**
     * Strip the `person/` prefix and surrounding whitespace from a MEP identifier.
     *
     * The EP API returns MEP ids in several forms (`person/124936`, `124936`,
     * `MEP-124936`). We normalise to the bare numeric token plus the original
     * string so substring matches against author fields succeed regardless of the
     * representation chosen by a given endpoint.
     *
     * @internal
     */
    export function normaliseMepIdTokens(id: string): string[] {
      const raw = id.trim();
      if (raw === '') return [];
      const tokens = new Set<string>([raw.toLowerCase()]);
      const slashIdx = raw.lastIndexOf('/');
      if (slashIdx >= 0 && slashIdx < raw.length - 1) {
        tokens.add(raw.slice(slashIdx + 1).toLowerCase());
      }
      const dashIdx = raw.lastIndexOf('-');
      if (dashIdx >= 0 && dashIdx < raw.length - 1) {
        tokens.add(raw.slice(dashIdx + 1).toLowerCase());
      }
      return [...tokens].filter((t) => t.length > 0);
    }

    /**
     * Tolerant author/MEP match.
     *
     * Returns `true` when any normalised token from `mepId` matches the candidate
     * exactly, or when the candidate contains a normalised token as a substring
     * (case-insensitive). Empty / undefined candidates never match.
     *
     * @internal
     */
    export function matchesMep(candidate: string | undefined | null, mepTokens: string[]): boolean {
      if (candidate === undefined || candidate === null || candidate === '') return false;
      if (mepTokens.length === 0) return false;
      const c = candidate.toLowerCase();
      for (const token of mepTokens) {
        Iif (token === '') continue;
        if (c === token) return true;
        // Require the token to be at least 2 chars to avoid spurious substring
        // matches against very short identifiers like "1".
        if (token.length >= 2 && c.includes(token)) return true;
      }
      return false;
    }

    /**
     * Build the set of identifier tokens that we treat as "authored by" the
     * subject. For an MEP this is the MEP id plus name tokens (so rapporteur
     * name-based matching works); for a committee it is the union of the
     * committee's member IDs.
     */
    export function buildSubjectTokens(
      subjectId: string,
      committeeMemberIds?: readonly string[],
      subjectName?: string,
    ): string[] {
      const result = new Set<string>();
      for (const token of normaliseMepIdTokens(subjectId)) result.add(token);
      if (subjectName !== undefined && subjectName.trim() !== '') {
        // Add the full name and individual name parts as match tokens.
        // The rapporteur field in procedures contains human-readable names
        // like "Jane Andersson" so we need name-based matching.
        const fullName = subjectName.trim().toLowerCase();
        result.add(fullName);
        // Add individual name parts (≥3 chars) for substring matching
        for (const part of fullName.split(/\s+/)) {
          if (part.length >= 3) result.add(part);
        }
      }
      if (committeeMemberIds !== undefined) {
        for (const memberId of committeeMemberIds) {
          for (const token of normaliseMepIdTokens(memberId)) result.add(token);
        }
      }
      return [...result];
    }

    // ---------------------------------------------------------------------------
    // Source filters
    // ---------------------------------------------------------------------------

    /** Inclusive ISO-date window check; missing/invalid dates are treated as out-of-window. */
    function inWindow(date: string | undefined | null, dateFrom: string, dateTo: string): boolean {
      if (date === undefined || date === null || date === '') return false;
      const d = date.length >= 10 ? date.slice(0, 10) : date;
      return d >= dateFrom && d <= dateTo;
    }

    /**
     * Round a number to 2 decimal places, returning `0` when the input is not
     * finite. Used pervasively for percentage / ratio fields so the public
     * envelope never emits `NaN` or `Infinity`.
     */
    export function roundToTwoDecimals(value: number): number {
      if (!Number.isFinite(value)) return 0;
      return Math.round(value * 100) / 100;
    }

    /** Shadow / opinion rapporteur fingerprints. */
    const SHADOW_OR_OPINION_KEYWORDS = ['shadow', 'opinion'];

    /**
     * Decide whether a procedure attributes the subject as the primary rapporteur
     * vs. a shadow- or opinion-rapporteur, vs. neither. Returns `null` when the
     * procedure carries no recognisable subject attribution.
     *
     * Heuristics:
     *  - `rapporteur` substring match (case-insensitive) against the subject's
     *    normalised tokens promotes the procedure to "report authored".
     *  - Combined with a `shadow` / `opinion` token in the same field, the
     *    procedure is reclassified as "opinion delivered".
     *  - `responsibleCommittee` is **not** sufficient on its own — it only enters
     *    the committee aggregation path via member-MEP attribution.
     */
    function classifyProcedureRole(
      procedure: Procedure,
      mepTokens: string[],
    ): 'report' | 'opinion' | null {
      // Defensive: TypeScript types `rapporteur` as `string`, but runtime EP API
      // payloads occasionally arrive with the field missing or null. Treat any
      // falsy value as "no attribution" so the aggregator never crashes on a
      // malformed item.
      const rapporteur = procedure.rapporteur;
      if (!rapporteur) return null;
      const matched = matchesMep(rapporteur, mepTokens);
      if (!matched) return null;
      const lower = rapporteur.toLowerCase();
      const hasOpinion = SHADOW_OR_OPINION_KEYWORDS.some((k) => lower.includes(k));
      if (hasOpinion) return 'opinion';
      // Default to "report" when the rapporteur field plainly names the MEP
      // (with or without a `rapporteur` qualifier). The opinion/shadow check
      // above already separated minority roles.
      return 'report';
    }

    /**
     * Decide whether a plenary-session document item should be counted as an
     * amendment authored by the subject. We look at:
     *  - `authors` array (preferred MEP attribution)
     *  - document `type` (e.g., `AMENDMENT`, `REPORT_AMENDMENTS`)
     *  - document `status` for the adoption flag (`ADOPTED` substring)
     */
    function isSubjectAmendment(doc: LegislativeDocument, mepTokens: string[]): boolean {
      const type = doc.type.toUpperCase();
      if (!type.includes('AMENDMENT')) return false;
      const authors = Array.isArray(doc.authors) ? doc.authors : [];
      return authors.some((author) => matchesMep(author, mepTokens));
    }

    function isDocumentAdopted(doc: LegislativeDocument): boolean {
      return doc.status.toUpperCase().includes('ADOPTED');
    }

    // ---------------------------------------------------------------------------
    // Aggregator
    // ---------------------------------------------------------------------------

    /**
     * Inputs to {@link aggregateLegislativeEffectiveness}.
     */
    export interface AggregatorInputs {
      /** Subject identifier (MEP ID or committee abbreviation). */
      subjectId: string;
      /**
       * Optional human-readable subject name (e.g. MEP full name). When provided,
       * enables rapporteur name-based matching against the procedure's `rapporteur`
       * field (which carries human-readable text like "Jane Andersson").
       */
      subjectName?: string;
      /**
       * Optional list of committee member MEP IDs. When provided, the aggregator
       * treats any rapporteur/author hit against a member as a hit for the
       * committee subject.
       */
      committeeMemberIds?: readonly string[];
      /** Inclusive ISO-date window for filtering procedures, documents, and questions. */
      dateFrom: string;
      /** Inclusive ISO-date window upper bound. */
      dateTo: string;
      /** Procedures fetched from `/procedures`. */
      procedures: readonly Procedure[];
      /** Adopted texts fetched from `/adopted-texts`. */
      adoptedTexts: readonly AdoptedText[];
      /** Plenary-session document items fetched from `/plenary-session-documents-items`. */
      plenaryDocumentItems: readonly LegislativeDocument[];
      /** Parliamentary questions fetched from `/parliamentary-questions`. */
      questions: readonly ParliamentaryQuestion[];
    }

    /**
     * Compute deterministic effectiveness metrics from the provided EP payloads.
     *
     * The function is pure: it never mutates its arguments, never throws, and
     * produces stable output for stable input. Empty inputs return zero metrics
     * (not `NaN`) so the surrounding tool can safely surface partial-source
     * outages.
     *
     * @param inputs - raw EP arrays plus subject metadata
     * @returns aggregated metrics + per-record attributions, sorted ascending
     *
     * @example
     * ```typescript
     * const result = aggregateLegislativeEffectiveness({
     *   subjectId: 'person/124936',
     *   dateFrom: '2024-01-01',
     *   dateTo: '2024-12-31',
     *   procedures, adoptedTexts, plenaryDocumentItems, questions,
     * });
     * // result.metrics.reportsAuthored, result.metrics.legislativeSuccessRate, ...
     * ```
     */
    interface ProcedureClassification {
      reportProcedures: Set<string>;
      opinionProcedures: Set<string>;
    }

    function classifyProcedures(
      procedures: readonly Procedure[],
      tokens: string[],
      dateFrom: string,
      dateTo: string,
    ): ProcedureClassification {
      const reportProcedures = new Set<string>();
      const opinionProcedures = new Set<string>();
      for (const proc of procedures) {
        if (!inWindow(proc.dateLastActivity, dateFrom, dateTo)
          && !inWindow(proc.dateInitiated, dateFrom, dateTo)) {
          continue;
        }
        const role = classifyProcedureRole(proc, tokens);
        if (role === 'report') reportProcedures.add(proc.id);
        else if (role === 'opinion') opinionProcedures.add(proc.id);
      }
      return { reportProcedures, opinionProcedures };
    }

    function buildAdoptedRefSet(
      adoptedTexts: readonly AdoptedText[],
      dateFrom: string,
      dateTo: string,
    ): Set<string> {
      const adoptedRefs = new Set<string>();
      for (const text of adoptedTexts) {
        Iif (!inWindow(text.dateAdopted, dateFrom, dateTo)) continue;
        const ref = text.procedureReference.trim();
        Eif (ref !== '') adoptedRefs.add(ref);
      }
      return adoptedRefs;
    }

    function countProceduresWithAdoption(
      procedures: readonly Procedure[],
      attributedIds: ReadonlySet<string>,
      adoptedRefs: ReadonlySet<string>,
    ): number {
      let count = 0;
      for (const proc of procedures) {
        if (!attributedIds.has(proc.id)) continue;
        const ref = proc.reference.trim();
        if ((ref !== '' && adoptedRefs.has(ref)) || adoptedRefs.has(proc.id)) {
          count += 1;
        }
      }
      return count;
    }

    function classifyAmendments(
      items: readonly LegislativeDocument[],
      tokens: string[],
      dateFrom: string,
      dateTo: string,
    ): { amendmentIds: Set<string>; amendmentAdoptedIds: Set<string> } {
      const amendmentIds = new Set<string>();
      const amendmentAdoptedIds = new Set<string>();
      for (const doc of items) {
        if (!inWindow(doc.date, dateFrom, dateTo)) continue;
        if (!isSubjectAmendment(doc, tokens)) continue;
        amendmentIds.add(doc.id);
        if (isDocumentAdopted(doc)) amendmentAdoptedIds.add(doc.id);
      }
      return { amendmentIds, amendmentAdoptedIds };
    }

    function classifyQuestions(
      questions: readonly ParliamentaryQuestion[],
      tokens: string[],
      dateFrom: string,
      dateTo: string,
    ): Set<string> {
      const ids = new Set<string>();
      for (const q of questions) {
        if (!inWindow(q.date, dateFrom, dateTo)) continue;
        if (!matchesMep(q.author, tokens)) continue;
        ids.add(q.id);
      }
      return ids;
    }

    function sortedAscending(items: ReadonlySet<string>): string[] {
      return [...items].sort((a, b) => a.localeCompare(b));
    }

    export function aggregateLegislativeEffectiveness(inputs: AggregatorInputs): AggregationResult {
      const tokens = buildSubjectTokens(inputs.subjectId, inputs.committeeMemberIds, inputs.subjectName);

      const { reportProcedures, opinionProcedures } = classifyProcedures(
        inputs.procedures, tokens, inputs.dateFrom, inputs.dateTo,
      );

      const adoptedRefs = buildAdoptedRefSet(inputs.adoptedTexts, inputs.dateFrom, inputs.dateTo);
      const attributedIds = new Set<string>([...reportProcedures, ...opinionProcedures]);
      const proceduresWithAdoptedText = countProceduresWithAdoption(
        inputs.procedures, attributedIds, adoptedRefs,
      );

      const { amendmentIds, amendmentAdoptedIds } = classifyAmendments(
        inputs.plenaryDocumentItems, tokens, inputs.dateFrom, inputs.dateTo,
      );
      const questionIds = classifyQuestions(inputs.questions, tokens, inputs.dateFrom, inputs.dateTo);

      const reportProcedureIds = sortedAscending(reportProcedures);
      const opinionProcedureIds = sortedAscending(opinionProcedures);
      const amendmentDocumentIds = sortedAscending(amendmentIds);
      const amendmentAdoptedDocumentIds = sortedAscending(amendmentAdoptedIds);
      const sortedQuestionIds = sortedAscending(questionIds);

      const attributedProcedureCount = attributedIds.size;
      const legislativeSuccessRate = attributedProcedureCount > 0
        ? roundToTwoDecimals((proceduresWithAdoptedText / attributedProcedureCount) * 100)
        : 0;

      return {
        metrics: {
          reportsAuthored: reportProcedureIds.length,
          opinionsDelivered: opinionProcedureIds.length,
          amendmentsTabled: amendmentDocumentIds.length,
          amendmentsAdopted: amendmentAdoptedDocumentIds.length,
          questionsAsked: sortedQuestionIds.length,
          legislativeSuccessRate,
        },
        attributions: {
          reportProcedureIds,
          opinionProcedureIds,
          amendmentDocumentIds,
          amendmentAdoptedDocumentIds,
          questionIds: sortedQuestionIds,
        },
        attributedProcedureCount,
        proceduresWithAdoptedText,
      };
    }