linkedin-studio/scripts/specifics-bank/src/binding.ts
Kjell Tore Guttormsen 33f18c35e6 feat(linkedin-studio): per-edition lived-specifics binding + kilder artifact (fix #2 slice 2)
Slice 2 of fix #2 (kilde-så-draft): the per-edition bridge between the global
specifics-bank (slice 1) and a draft. In scripts/specifics-bank:

- binding.ts — livedSpecifics slot-map model + validateBinding, the «vaghet
  avvises» gate (G4). BLOCK on unresolved / dangling specific-id / unjustified
  abstrakt; WARN on an unverified-number-backed slot (regel 6/7); PASS otherwise.
- kilder.ts — renderKilder, the read-only NN-kilder.md sources ledger, rendered
  deterministically from the slot-map + bank (coverage via validateBinding, so
  the artifact and the skeleton-gate cannot disagree).
- cli.ts — validate-binding (exit 1 = BLOCK) + render-kilder subcommands.

edition-state schema gains articles.NN.livedSpecifics ({ slots, status }) + _doc.
Design record + the Step 2 research re-scope spec: docs/fix2/slice2-binding.md.

Wiring into newsletter.md (Step 1.5 elicitation + Step 2.5 gate + pipeline 17->18)
is slice 3 — not in this commit.

Tests 28/28 (specifics-bank; +9 binding +4 kilder), tsc clean, plugin gate 83/0/0
unaffected, gitleaks clean on all 8 files.

[skip-docs] internal plumbing — no command/agent/pipeline surface change until
slice 3 wires it; documenting it in the user-facing root CLAUDE.md/README now would
overclaim unreachable functionality. Docs ARE updated where they belong: the design
record docs/fix2/slice2-binding.md, scripts/specifics-bank/README.md, and the
edition-state _doc. Root-doc update lands with the slice-3 wiring feat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:33:44 +02:00

156 lines
5.2 KiB
TypeScript

/**
* 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<string, Specific>(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,
};
}