/** * Per-edition lived-specifics BINDING (Fix #2, slice 2 — kilde-så-draft). * * The bank (`bank.ts`) is the operator's global inventory of real material. This * module is the per-EDITION bridge: a slot-map that binds each load-bearing * key-point (or, later, section) of one edition to either a real specific from * the bank OR an explicit escape decision. It lives in the edition's * `edition-state.json` under `articles.NN.livedSpecifics`; this module only * validates and reports on it (pure, testable). The elicitation that POPULATES * the slot-map — the guided interview that refuses vague answers — lives in the * command layer (`/linkedin:newsletter` Step 1.5, slice 3). * * THE GATE (retning §3 + drømme-spec G4, «vaghet avvises»): a load-bearing claim * must be grounded in real material (`specific`) or explicitly escape-hatched * (`abstrakt` with a justification, or `ekstern` = backed by research, not lived). * A slot left `unresolved` is the vagueness the gate rejects → BLOCK. Numbers * stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but * WARNED (regel 6/7), so Step 2 research verifies it before a draft asserts it. */ import type { Bank, Specific } from "./types.js"; /** A slot is a load-bearing key-point (Step 1.5) or a section (Step 2.5 refinement). */ export type SlotKind = "key-point" | "section"; /** How a slot is grounded. `unresolved` is the vagueness the gate blocks on. */ export type Binding = | { type: "specific"; specificId: string } // grounded in real bank material (best) | { type: "abstrakt"; rationale: string } // deliberately abstract here — must justify | { type: "ekstern"; source?: string } // backed by external research, not lived material | { type: "unresolved" }; // not yet decided → BLOCK export interface Slot { slotId: string; kind: SlotKind; /** Human-readable claim/key-point text. */ label: string; binding: Binding; } export type LivedSpecificsStatus = "pending" | "bound"; export interface LivedSpecifics { slots: Slot[]; status: LivedSpecificsStatus; } export type BindingVerdict = "PASS" | "BLOCK"; export type SlotProblemReason = "unresolved" | "dangling-specific" | "unjustified-abstrakt"; export interface SlotProblem { slotId: string; reason: SlotProblemReason; detail: string; } export interface SlotWarning { slotId: string; reason: "unverified-number"; detail: string; } /** Coverage counts by binding TYPE (a dangling specific still counts under `specific`). */ export interface BindingCoverage { total: number; specific: number; abstrakt: number; ekstern: number; unresolved: number; } export interface BindingResult { verdict: BindingVerdict; /** BLOCK reasons — every offending slot, not short-circuited. */ problems: SlotProblem[]; /** Non-blocking direction (regel 6/7 number-verification). */ warnings: SlotWarning[]; coverage: BindingCoverage; } const isBlank = (s: string): boolean => s.trim().length === 0; /** * Validate one edition's slot-map against the bank. Pure: no I/O, no clock. * BLOCK iff any slot is `unresolved`, points at a `specificId` absent from the * bank, or is an `abstrakt` escape with a blank rationale. Otherwise PASS. * Warnings (do not block) flag slots backed by an unverified number (regel 6/7). */ export function validateBinding(ls: LivedSpecifics, bank: Bank): BindingResult { const byId = new Map(bank.specifics.map((s) => [s.id, s])); const problems: SlotProblem[] = []; const warnings: SlotWarning[] = []; const coverage: BindingCoverage = { total: ls.slots.length, specific: 0, abstrakt: 0, ekstern: 0, unresolved: 0, }; for (const slot of ls.slots) { const b = slot.binding; switch (b.type) { case "specific": { coverage.specific++; const spec = byId.get(b.specificId); if (!spec) { problems.push({ slotId: slot.slotId, reason: "dangling-specific", detail: `specificId '${b.specificId}' is not in the bank`, }); } else if (spec.type === "number" && spec.verification === "unverified") { warnings.push({ slotId: slot.slotId, reason: "unverified-number", detail: `number '${b.specificId}' is unverified — verify before the draft asserts it (regel 6/7)`, }); } break; } case "abstrakt": { coverage.abstrakt++; if (isBlank(b.rationale)) { problems.push({ slotId: slot.slotId, reason: "unjustified-abstrakt", detail: "an abstrakt escape needs a rationale — an unjustified abstraction is vaghet", }); } break; } case "ekstern": { coverage.ekstern++; // source optional — Step 2 research fills it break; } case "unresolved": { coverage.unresolved++; problems.push({ slotId: slot.slotId, reason: "unresolved", detail: "slot is unresolved — bind it to a specific, or escape-hatch as abstrakt/ekstern", }); break; } } } return { verdict: problems.length === 0 ? "PASS" : "BLOCK", problems, warnings, coverage, }; }