core/converters/invariant.converter.ts

core/converters/invariant.converter.ts is a file in Bane's Lab Content. 173 lines of code and 45 definitions.

import {
    BOLD_MARK,
    HEADING_MARK,
    LEAD_IN_SEPARATOR,
    LINE_END,
    LIST_MARKER,
} from "#configuration/constants/inventory.constants";
import {
    GATE_HEADING,
    ITS_PREFIX,
    MODEL_EXTENSION,
    SEED_ID_SEPARATOR,
    SEED_LEVELS,
    SLOT_SENTENCE_LEAD,
} from "#configuration/constants/seed.constants";
import type { InvariantStatement, LessonSeed, SlotWords } from "#types/lesson.types";
import type { SourceText } from "#types/inventory.types";
import { slugify } from "@govlab/context";

const SOURCE = "model";
const NO_LEAD = "with no ";
const STATEMENT_LEADS: readonly string[] = [LEAD_IN_SEPARATOR, ".", ":", ",", " "];
const LABEL_STOPS: ReadonlySet<string> = new Set([".", ",", ":", " "]);
const MIN_WORD = 3;

const isUpperWord = function isUpperWord(word: string): boolean {
    if (word.length < MIN_WORD) {
        return false;
    }
    for (const char of word) {
        if (char < "A" || char > "Z") {
            return false;
        }
    }
    return true;
};

const upperWordsAfterLead = function upperWordsAfterLead(text: string): string[] {
    const lead = text.indexOf(SLOT_SENTENCE_LEAD);
    const close = lead === -1 ? -1 : text.indexOf(BOLD_MARK, lead);
    if (close === -1) {
        return [];
    }
    const paragraphEnd = text.indexOf(LINE_END + LINE_END, close);
    const body = text.slice(close, paragraphEnd === -1 ? text.length : paragraphEnd);
    return body.split(" ").filter(isUpperWord);
};

const objectorWord = function objectorWord(text: string): string | null {
    const gate = text.indexOf(GATE_HEADING);
    const at = gate === -1 ? -1 : text.indexOf(NO_LEAD, gate);
    if (at === -1) {
        return null;
    }
    const rest = text.slice(at + NO_LEAD.length);
    const end = rest.indexOf(" ");
    return end === -1 ? rest : rest.slice(0, end);
};

export const slotWordsOf = function slotWordsOf(template: string): SlotWords | null {
    const [property, set, parties] = upperWordsAfterLead(template).map((word) => word.toLowerCase());
    const objector = objectorWord(template);
    if (property === undefined || set === undefined || parties === undefined || objector === null) {
        return null;
    }
    return { objector, parties, property, set };
};

const firstWordOf = function firstWordOf(label: string): string {
    const lower = label.trim().toLowerCase();
    const bare = lower.startsWith(ITS_PREFIX.toLowerCase()) ? lower.slice(ITS_PREFIX.length) : lower;
    let end = 0;
    while (end < bare.length && !LABEL_STOPS.has(bare.charAt(end))) {
        end += 1;
    }
    return bare.slice(0, end);
};

const stripLeads = function stripLeads(text: string): string {
    let out = text;
    let changed = true;
    while (changed) {
        changed = false;
        for (const lead of STATEMENT_LEADS) {
            if (out.startsWith(lead)) {
                out = out.slice(lead.length);
                changed = true;
            }
        }
    }
    return out.trim();
};

const slotOf = function slotOf(label: string, words: SlotWords): keyof SlotWords | null {
    const word = firstWordOf(label);
    const entries: [keyof SlotWords, string][] = [
        ["property", words.property],
        ["set", words.set],
        ["parties", words.parties],
        ["objector", words.objector],
    ];
    return entries.find(([, held]) => held === word)?.[0] ?? null;
};

const unitsOf = function unitsOf(block: string): string[] {
    const units: string[] = [];
    for (const line of block.split(LINE_END)) {
        const last = units.length - 1;
        const previous = units[last];
        if (line.trim().length === 0 || line.startsWith(LIST_MARKER) || previous === undefined) {
            units.push(line);
            continue;
        }
        units[last] = `${previous} ${line.trim()}`;
    }
    return units;
};

const statementsOf = function statementsOf(block: string, words: SlotWords): Partial<Record<keyof SlotWords, string>> {
    const found: Partial<Record<keyof SlotWords, string>> = {};
    for (const line of unitsOf(block)) {
        const segments = line.split(BOLD_MARK);
        for (let index = 1; index < segments.length; index += 2) {
            const slot = slotOf(segments[index] ?? "", words);
            const statement = stripLeads(segments[index + 1] ?? "");
            if (slot !== null && found[slot] === undefined && statement.length > 0) {
                found[slot] = statement;
            }
        }
    }
    return found;
};

const blocksOf = function blocksOf(text: string): { heading: string; body: string }[] {
    const blocks: { heading: string; body: string }[] = [];
    for (const line of text.split(LINE_END)) {
        if (line.startsWith(HEADING_MARK + HEADING_MARK)) {
            let at = 0;
            while (line.charAt(at) === HEADING_MARK) {
                at += 1;
            }
            blocks.push({ body: "", heading: line.slice(at).trim() });
            continue;
        }
        const last = blocks.at(-1);
        if (last !== undefined) {
            last.body += line + LINE_END;
        }
    }
    return blocks;
};

export const invariantsOf = function invariantsOf(text: string, words: SlotWords): InvariantStatement[] {
    const out: InvariantStatement[] = [];
    for (const block of blocksOf(text)) {
        const found = statementsOf(block.body, words);
        const { objector, parties, property, set } = found;
        if (objector !== undefined && parties !== undefined && property !== undefined && set !== undefined) {
            out.push({ heading: block.heading, objector, parties, property, set });
        }
    }
    return out;
};

const modelStem = function modelStem(path: string): string {
    const base = path.slice(path.lastIndexOf("/") + 1);
    return base.endsWith(MODEL_EXTENSION) ? base.slice(0, -MODEL_EXTENSION.length) : base;
};

export const modelSeeds = function modelSeeds(models: readonly SourceText[], words: SlotWords): LessonSeed[] {
    return models.flatMap((model) =>
        invariantsOf(model.text, words).map((held) => ({
            application: [held.set],
            boundary: null,
            cause: [],
            decision: held.parties,
            domain: modelStem(model.path),
            failureMode: held.objector,
            id: modelStem(model.path) + SEED_ID_SEPARATOR + slugify(held.heading),
            level: SEED_LEVELS[SOURCE],
            principle: held.property,
            problem: null,
            source: SOURCE,
            validation: held.objector,
        })),
    );
};