core/analyzers/graph.analyzer.ts

core/analyzers/graph.analyzer.ts is a file in Bane's Lab Build Scripts. 116 lines of code and 22 definitions.

import { COVERED_KINDS, ONTOLOGY_FACET, SITE_LAYER, SOURCE_FACET } from "#configuration/constants/graph.constants";
import type { ChunkRules, Graph, TooltipGap } from "#types/graph.types";
import type { GraphChunk, GraphNode } from "@banes-lab/web/types/graph.types.js";
import type { Population } from "#types/catalog.types";
import type { SectionPlan } from "#types/section.types";
import { isListingRef } from "@banes-lab/web/constants/graph.constants";

const stateOf = function stateOf(node: GraphNode, has: (node: GraphNode) => boolean): string {
    if (isListingRef(node.ref)) {
        return "listing";
    }
    return has(node) ? "present" : "absent";
};

export const presence = function presence(
    name: string,
    nodes: readonly GraphNode[],
    has: (node: GraphNode) => boolean,
): Population {
    const parts = new Map<string, number>();
    for (const held of nodes) {
        const key = `${stateOf(held, has)} ${held.layer} ${held.kind}`;
        parts.set(key, (parts.get(key) ?? 0) + 1);
    }
    return {
        name,
        parts: Object.fromEntries([...parts].toSorted(([a], [b]) => a.localeCompare(b))),
        whole: nodes.length,
    };
};

const facetOf = function facetOf(layer: string): string {
    return layer.startsWith(SOURCE_FACET) ? SOURCE_FACET : layer;
};

export const reaches = function reaches(graph: Graph, facet: string): (node: GraphNode) => boolean {
    const layerOf = new Map(graph.nodes.map((node) => [node.ref, facetOf(node.layer)]));
    const reached = new Set<string>();
    for (const edge of graph.edges.filter((held) => !isListingRef(held.from))) {
        if (layerOf.get(edge.to) === facet) {
            reached.add(edge.from);
        }
        if (layerOf.get(edge.from) === facet) {
            reached.add(edge.to);
        }
    }
    return (node) => reached.has(node.ref);
};

export const coverage = function coverage(graph: Graph): readonly Population[] {
    const facets = [SITE_LAYER, ONTOLOGY_FACET, SOURCE_FACET];
    return facets.flatMap((from) =>
        facets
            .filter((to) => to !== from)
            .map((to) =>
                presence(
                    `${from} to ${to}`,
                    graph.nodes.filter(
                        (node) =>
                            facetOf(node.layer) === from && (from === ONTOLOGY_FACET || COVERED_KINDS.has(node.kind)),
                    ),
                    reaches(graph, to),
                ),
            ),
    );
};

export const layerPopulation = function layerPopulation(nodes: readonly GraphNode[]): Population {
    const parts = new Map<string, number>();
    for (const held of nodes) {
        parts.set(held.layer, (parts.get(held.layer) ?? 0) + 1);
    }
    return { name: "nodes by layer", parts: Object.fromEntries(parts), whole: nodes.length };
};

export const uncoveredSections = function uncoveredSections(
    plans: readonly SectionPlan[],
    teaching: ReadonlySet<string>,
    graph: Graph,
    nodeOf: (page: string, section: string) => string,
): readonly string[] {
    const linksOntology = reaches(graph, ONTOLOGY_FACET);
    const byRef = new Map(graph.nodes.map((held) => [held.ref, held]));
    return plans
        .filter((plan) => teaching.has(nodeOf(plan.page.page, plan.section.id)))
        .map((plan) => plan.identity.ref)
        .filter((ref) => {
            const node = byRef.get(ref);
            return node !== undefined && !linksOntology(node);
        });
};

const carries = function carries(
    chunk: GraphChunk | undefined,
    from: string,
    relation: string,
    to: GraphNode,
): boolean {
    const held = chunk?.[from]?.relations.find((entry) => entry.relation === relation);
    return held?.targets.some((target) => target.href === to.href && target.title === to.title) === true;
};

export const tooltipGaps = function tooltipGaps(
    graph: Graph,
    chunks: ReadonlyMap<string, GraphChunk>,
    rules: ChunkRules,
): readonly TooltipGap[] {
    const nodes = new Map(graph.nodes.map((node) => [node.ref, node]));
    const expected = graph.edges
        .filter((edge) => !rules.skipsSource(edge.from))
        .flatMap((edge) => {
            const reverse = rules.pairs.find((pair) => pair.forward === edge.relation)?.reverse;
            return [
                { from: edge.from, relation: edge.relation, to: edge.to },
                ...(reverse === undefined ? [] : [{ from: edge.to, relation: reverse, to: edge.from }]),
            ];
        })
        .filter((entry) => rules.keptRelations.has(entry.relation));
    return expected.filter((entry) => {
        const target = nodes.get(entry.to);
        return (
            target === undefined || !carries(chunks.get(rules.keyOf(entry.from)), entry.from, entry.relation, target)
        );
    });
};