core/aggregators/representation.graph.aggregator.ts

core/aggregators/representation.graph.aggregator.ts is a file in GovLab Patterns. 111 lines of code and 27 definitions.

import {
    MEMBER_SEP,
    combinations2,
    compositionOf,
    countAdjacent,
    liftOf,
    orderedOf,
    positionalOf,
    slotPatternsOf,
    topPairsOf,
} from "#core/analyzers/representation.graph.analyzer";
import { topEntries, weightedSample } from "#core/selectors/counter.selector";
import type { GraphSummary } from "#types/representation.types";
import type { Rng } from "#types/seed.types";
import { foldRecords } from "#core/selectors/record.selector";
import { increment } from "#core/counters/base.counter";
import { uniformity } from "#core/analyzers/baseline.analyzer";

const DEFAULT_TOP = 10;

export class GraphAccumulator {
    private readonly field: string;
    private readonly members = new Map<string, number>();
    private readonly pairs = new Map<string, number>();
    private readonly numeric = new Map<number, number>();
    private readonly positions = new Map<number, Map<string, number>>();
    private readonly sets = new Set<string>();
    private readonly slots = new Map<string, number>();
    private records = 0;
    private listRecords = 0;
    private edges = 0;
    private maxDegree = 0;
    private adjacent = 0;
    private allNumeric = true;

    public constructor(field: string) {
        this.field = field;
    }

    public update(chunk: readonly unknown[]): void {
        foldRecords(chunk, this.field, (value) => {
            this.observe(value);
        });
    }

    public sample(rng: Rng): string[] {
        if (this.members.size === 0 || this.listRecords === 0) {
            return [];
        }
        return weightedSample(rng, this.members, Math.max(1, Math.round(this.edges / this.listRecords)));
    }

    public result(top = DEFAULT_TOP): GraphSummary {
        return {
            composition: this.allNumeric ? compositionOf(this.numeric) : null,
            distinctSets: this.sets.size,
            distinctTargets: this.members.size,
            edges: this.edges,
            field: this.field,
            maxDegree: this.maxDegree,
            meanDegree: this.records ? this.edges / this.records : 0,
            memberUniformity: uniformity(this.members),
            ordered: this.allNumeric ? orderedOf(this.numeric, this.adjacent, this.listRecords) : null,
            positional: positionalOf(this.positions),
            records: this.records,
            repeatRate: this.listRecords ? 1 - this.sets.size / this.listRecords : 0,
            slotPatterns: slotPatternsOf(this.slots, top),
            topLift: liftOf(this.pairs, this.members, { limit: top, records: this.records }),
            topMembers: topEntries(this.members, top),
            topPairs: topPairsOf(this.pairs, top),
        };
    }

    private observe(value: unknown): void {
        this.records += 1;
        if (!Array.isArray(value)) {
            return;
        }
        this.listRecords += 1;
        this.edges += value.length;
        this.maxDegree = Math.max(this.maxDegree, value.length);
        const members = [...new Set(value.map(String))].sort((a, b) => a.localeCompare(b));
        this.recordMembers(members);
        this.recordPositions(value.map(String));
        this.observeNumeric(new Set(value));
    }

    private recordMembers(members: readonly string[]): void {
        for (const member of members) {
            increment(this.members, member);
        }
        this.sets.add(members.join(MEMBER_SEP));
        for (const [a, b] of combinations2(members)) {
            increment(this.pairs, `${a}${MEMBER_SEP}${b}`);
        }
    }

    private recordPositions(items: readonly string[]): void {
        items.forEach((item, index) => {
            const counter = this.positions.get(index) ?? new Map<string, number>();
            this.positions.set(index, counter);
            increment(counter, item);
        });
        for (let i = 0; i + 1 < items.length; i += 1) {
            increment(this.slots, `${i}${MEMBER_SEP}${items[i]}${MEMBER_SEP}${items[i + 1]}`);
        }
    }

    private observeNumeric(distinct: ReadonlySet<unknown>): void {
        const numbers = [...distinct].filter((item): item is number => typeof item === "number");
        if (numbers.length !== distinct.size) {
            this.allNumeric = false;
            return;
        }
        const values = numbers.toSorted((a, b) => a - b);
        for (const value of values) {
            increment(this.numeric, value);
        }
        this.adjacent += countAdjacent(values);
    }
}