core/analyzers/representation.distribution.analyzer.ts

core/analyzers/representation.distribution.analyzer.ts is a file in GovLab Patterns. 98 lines of code and 26 definitions.

import type { DriftContext, Temporal } from "#types/representation.types";
import { byCountDesc } from "#core/selectors/counter.selector";
import { increment } from "#core/counters/base.counter";

const HALF = 0.5;
const YEAR_END = 4;
const MONTH_START = 5;
const MONTH_END = 7;
const ISO_LENGTH = 10;
const DATE_SEPARATOR = "-";
const DIGITS: ReadonlySet<string> = new Set(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]);

interface MonthBuckets {
    byMonth: Map<number, number>;
    moments: string[];
}

const byMagnitudeDesc = function byMagnitudeDesc(a: readonly [string, number], b: readonly [string, number]): number {
    return Math.abs(b[1]) - Math.abs(a[1]) || a[0].localeCompare(b[0]);
};

const isoMonth = function isoMonth(value: string): number | null {
    if (value.length !== ISO_LENGTH || value[YEAR_END] !== DATE_SEPARATOR || value[MONTH_END] !== DATE_SEPARATOR) {
        return null;
    }
    for (let i = 0; i < value.length; i += 1) {
        if (i !== YEAR_END && i !== MONTH_END && !DIGITS.has(value[i] ?? "")) {
            return null;
        }
    }
    return Number(value.slice(MONTH_START, MONTH_END));
};

const bucketMonths = function bucketMonths(counts: ReadonlyMap<string, number>): MonthBuckets | null {
    const byMonth = new Map<number, number>();
    const moments: string[] = [];
    for (const [value, count] of counts) {
        const month = isoMonth(value);
        if (month === null) {
            return null;
        }
        increment(byMonth, month, count);
        moments.push(value);
    }
    return { byMonth, moments };
};

export const temporalOf = function temporalOf(counts: ReadonlyMap<string, number>): Temporal | null {
    const bucketed = bucketMonths(counts);
    if (bucketed === null || bucketed.moments.length === 0) {
        return null;
    }
    const sorted = bucketed.moments.toSorted((a, b) => a.localeCompare(b));
    return {
        byMonth: [...bucketed.byMonth.entries()].sort((a, b) => a[0] - b[0]),
        first: sorted[0] ?? "",
        last: sorted.at(-1) ?? "",
    };
};

export const witnessesOf = function witnessesOf(
    top: readonly [string, number][],
    lastSeen: ReadonlyMap<string, number>,
): [string, number][] {
    return top.map(([value]): [string, number] => [value, lastSeen.get(value) ?? 0]);
};

export const overdueOf = function overdueOf(
    lastSeen: ReadonlyMap<string, number>,
    total: number,
    limit: number,
): [string, number][] {
    return [...lastSeen.entries()]
        .map(([value, index]): [string, number] => [value, total - 1 - index])
        .sort(byCountDesc)
        .slice(0, limit);
};

export const temperaturesOf = function temperaturesOf(
    counts: ReadonlyMap<string, number>,
    window: readonly string[],
    total: number,
): [string, number][] {
    const recent = new Map<string, number>();
    for (const value of window) {
        increment(recent, value);
    }
    return [...counts.entries()].map(([value, count]): [string, number] => [
        value,
        (recent.get(value) ?? 0) - (count / total) * window.length,
    ]);
};

export const driftOf = function driftOf(
    counts: ReadonlyMap<string, number>,
    context: DriftContext,
): [string, number][] {
    const { indexSum, total, limit } = context;
    if (total <= 1) {
        return [];
    }
    const midpoint = (total - 1) * HALF;
    const scored = [...counts.keys()].map((value): [string, number] => [
        value,
        ((indexSum.get(value) ?? 0) / (counts.get(value) ?? 1) - midpoint) / (total - 1),
    ]);
    return scored.toSorted(byMagnitudeDesc).slice(0, limit);
};