core/analyzers/representation.graph.analyzer.ts

core/analyzers/representation.graph.analyzer.ts is a file in GovLab Patterns. 108 lines of code and 31 definitions.

import type { Composition, LiftContext, NumericBounds, OrderedStats } from "#types/representation.types";
import { byCountDesc } from "#core/selectors/counter.selector";

const PAIR = 2;
const BANDS = 3;
const MIN_PATTERN = 2;

export const MEMBER_SEP = "␟";

export const combinations2 = function combinations2(members: readonly string[]): string[][] {
    return members.flatMap((a, i) => members.slice(i + 1).map((b) => [a, b]));
};

const numericBounds = function numericBounds(numeric: ReadonlyMap<number, number>): NumericBounds {
    let total = 0;
    let weighted = 0;
    let low = Infinity;
    let high = -Infinity;
    for (const [value, count] of numeric) {
        total += count;
        weighted += value * count;
        low = Math.min(low, value);
        high = Math.max(high, value);
    }
    return { high, low, total, weighted };
};

const fillBands = function fillBands(numeric: ReadonlyMap<number, number>, low: number, span: number): number[] {
    const bands = [0, 0, 0];
    for (const [value, count] of numeric) {
        const band = span === 0 ? 0 : Math.min(Math.floor(((value - low) / span) * BANDS), BANDS - 1);
        bands[band] = (bands[band] ?? 0) + count;
    }
    return bands;
};

export const orderedOf = function orderedOf(
    numeric: ReadonlyMap<number, number>,
    adjacent: number,
    listRecords: number,
): OrderedStats | null {
    const { low, high, total, weighted } = numericBounds(numeric);
    if (total === 0) {
        return null;
    }
    const span = high - low;
    const midpoint = (low + high) / PAIR;
    const symmetry = span > 0 ? 1 - Math.abs(weighted / total - midpoint) / (span / PAIR) : 1;
    return { adjacencyRate: listRecords ? adjacent / listRecords : 0, bands: fillBands(numeric, low, span), symmetry };
};

export const compositionOf = function compositionOf(numeric: ReadonlyMap<number, number>): Composition | null {
    const { low, high, total } = numericBounds(numeric);
    if (total === 0) {
        return null;
    }
    const midpoint = (low + high) / PAIR;
    let odd = 0;
    let above = 0;
    for (const [value, count] of numeric) {
        odd += Number.isInteger(value) && value % PAIR !== 0 ? count : 0;
        above += value > midpoint ? count : 0;
    }
    return { highRatio: above / total, oddRatio: odd / total };
};

export const countAdjacent = function countAdjacent(values: readonly number[]): number {
    return values.slice(1).filter((next, index) => next - (values[index] ?? next) === 1).length;
};

export const positionalOf = function positionalOf(
    positions: ReadonlyMap<number, Map<string, number>>,
): [number, string, number][] {
    return [...positions.keys()]
        .sort((a, b) => a - b)
        .map((index): [number, string, number] => {
            const [value, count] = [...(positions.get(index) ?? new Map<string, number>()).entries()].sort(
                byCountDesc,
            )[0] ?? ["", 0];
            return [index, value, count];
        });
};

export const liftOf = function liftOf(
    pairs: ReadonlyMap<string, number>,
    members: ReadonlyMap<string, number>,
    context: LiftContext,
): [string[], number][] {
    if (context.records === 0) {
        return [];
    }
    const scored = [...pairs.entries()].map(([key, observed]): [string[], number] => {
        const [a = "", b = ""] = key.split(MEMBER_SEP);
        return [[a, b], (observed * context.records) / ((members.get(a) ?? 1) * (members.get(b) ?? 1))];
    });
    return scored
        .toSorted((x, y) => y[1] - x[1] || x[0].join(",").localeCompare(y[0].join(",")))
        .slice(0, context.limit);
};

export const topPairsOf = function topPairsOf(pairs: ReadonlyMap<string, number>, limit: number): [string[], number][] {
    return [...pairs.entries()]
        .sort(byCountDesc)
        .slice(0, limit)
        .map(([key, count]): [string[], number] => [key.split(MEMBER_SEP), count]);
};

export const slotPatternsOf = function slotPatternsOf(
    slots: ReadonlyMap<string, number>,
    limit: number,
): [number, string, string, number][] {
    return [...slots.entries()]
        .sort(byCountDesc)
        .slice(0, limit)
        .filter(([, count]) => count >= MIN_PATTERN)
        .map(([key, count]): [number, string, string, number] => {
            const [index = "", left = "", right = ""] = key.split(MEMBER_SEP);
            return [Number(index), left, right, count];
        });
};