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>
This commit is contained in:
parent
b390b87ad5
commit
33f18c35e6
8 changed files with 744 additions and 1 deletions
151
scripts/specifics-bank/tests/binding.test.ts
Normal file
151
scripts/specifics-bank/tests/binding.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { validateBinding } from "../src/binding.js";
|
||||
import type { LivedSpecifics, Slot } from "../src/binding.js";
|
||||
import type { Bank, Specific } from "../src/types.js";
|
||||
|
||||
// ---- fixtures ---------------------------------------------------------------
|
||||
|
||||
function mkSpecific(over: Partial<Specific> & { id: string }): Specific {
|
||||
return {
|
||||
id: over.id,
|
||||
type: over.type ?? "other",
|
||||
content: over.content ?? "x",
|
||||
topicTags: over.topicTags ?? ["t"],
|
||||
provenance: over.provenance ?? { capturedAt: "2026-06-20", source: "test" },
|
||||
verification: over.verification ?? "n/a",
|
||||
status: over.status ?? "active",
|
||||
};
|
||||
}
|
||||
|
||||
const mkBank = (specifics: Specific[]): Bank => ({ schemaVersion: 1, specifics });
|
||||
|
||||
const slot = (slotId: string, binding: Slot["binding"], over: Partial<Slot> = {}): Slot => ({
|
||||
slotId,
|
||||
kind: over.kind ?? "key-point",
|
||||
label: over.label ?? `label ${slotId}`,
|
||||
binding,
|
||||
});
|
||||
|
||||
const ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" });
|
||||
|
||||
// ---- tests ------------------------------------------------------------------
|
||||
|
||||
describe("validateBinding — the «vaghet avvises» gate (G4)", () => {
|
||||
test("every slot resolved (specific + abstrakt + ekstern) → PASS, coverage counted", () => {
|
||||
const bank = mkBank([mkSpecific({ id: "aaa", type: "named-case" })]);
|
||||
const res = validateBinding(
|
||||
ls([
|
||||
slot("kp1", { type: "specific", specificId: "aaa" }),
|
||||
slot("kp2", { type: "abstrakt", rationale: "bevisst abstrakt her" }),
|
||||
slot("kp3", { type: "ekstern", source: "research note 2" }),
|
||||
]),
|
||||
bank,
|
||||
);
|
||||
assert.equal(res.verdict, "PASS");
|
||||
assert.equal(res.problems.length, 0);
|
||||
assert.deepEqual(res.coverage, {
|
||||
total: 3,
|
||||
specific: 1,
|
||||
abstrakt: 1,
|
||||
ekstern: 1,
|
||||
unresolved: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("an unresolved slot → BLOCK with reason 'unresolved'", () => {
|
||||
const res = validateBinding(
|
||||
ls([
|
||||
slot("kp1", { type: "specific", specificId: "aaa" }),
|
||||
slot("kp2", { type: "unresolved" }),
|
||||
]),
|
||||
mkBank([mkSpecific({ id: "aaa" })]),
|
||||
);
|
||||
assert.equal(res.verdict, "BLOCK");
|
||||
assert.ok(
|
||||
res.problems.some((p) => p.slotId === "kp2" && p.reason === "unresolved"),
|
||||
"kp2 should be flagged unresolved",
|
||||
);
|
||||
assert.equal(res.coverage.unresolved, 1);
|
||||
});
|
||||
|
||||
test("a specific slot whose id is not in the bank → BLOCK 'dangling-specific'", () => {
|
||||
const res = validateBinding(
|
||||
ls([slot("kp1", { type: "specific", specificId: "missing" })]),
|
||||
mkBank([]),
|
||||
);
|
||||
assert.equal(res.verdict, "BLOCK");
|
||||
assert.ok(
|
||||
res.problems.some((p) => p.slotId === "kp1" && p.reason === "dangling-specific"),
|
||||
"dangling id must be caught",
|
||||
);
|
||||
// dangling still counts under `specific` in coverage (binding TYPE is specific)
|
||||
assert.equal(res.coverage.specific, 1);
|
||||
});
|
||||
|
||||
test("an abstrakt escape with a blank rationale is vaghet → BLOCK 'unjustified-abstrakt'", () => {
|
||||
const res = validateBinding(ls([slot("kp1", { type: "abstrakt", rationale: " " })]), mkBank([]));
|
||||
assert.equal(res.verdict, "BLOCK");
|
||||
assert.ok(
|
||||
res.problems.some((p) => p.slotId === "kp1" && p.reason === "unjustified-abstrakt"),
|
||||
"an unjustified abstraction must be rejected",
|
||||
);
|
||||
});
|
||||
|
||||
test("ekstern needs no source (research fills it later) → PASS", () => {
|
||||
const res = validateBinding(
|
||||
ls([
|
||||
slot("a", { type: "ekstern" }),
|
||||
slot("b", { type: "abstrakt", rationale: "x" }),
|
||||
slot("c", { type: "abstrakt", rationale: "y" }),
|
||||
]),
|
||||
mkBank([]),
|
||||
);
|
||||
assert.equal(res.verdict, "PASS");
|
||||
assert.deepEqual(res.coverage, {
|
||||
total: 3,
|
||||
specific: 0,
|
||||
abstrakt: 2,
|
||||
ekstern: 1,
|
||||
unresolved: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("a slot backed by an UNVERIFIED number → PASS + warning (regel 6/7)", () => {
|
||||
const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "unverified" })]);
|
||||
const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank);
|
||||
assert.equal(res.verdict, "PASS", "an unverified number warns, it does not block");
|
||||
assert.ok(
|
||||
res.warnings.some((w) => w.slotId === "kp1" && w.reason === "unverified-number"),
|
||||
"unverified number must surface as a warning",
|
||||
);
|
||||
});
|
||||
|
||||
test("a slot backed by a VERIFIED number → PASS, no warning", () => {
|
||||
const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "verified" })]);
|
||||
const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank);
|
||||
assert.equal(res.verdict, "PASS");
|
||||
assert.equal(res.warnings.length, 0);
|
||||
});
|
||||
|
||||
test("empty slot-map → PASS, total 0 (the command decides whether ≥1 slot is required)", () => {
|
||||
const res = validateBinding(ls([]), mkBank([]));
|
||||
assert.equal(res.verdict, "PASS");
|
||||
assert.equal(res.coverage.total, 0);
|
||||
assert.equal(res.problems.length, 0);
|
||||
});
|
||||
|
||||
test("multiple problems are all reported (gate is not short-circuited)", () => {
|
||||
const res = validateBinding(
|
||||
ls([
|
||||
slot("a", { type: "unresolved" }),
|
||||
slot("b", { type: "specific", specificId: "nope" }),
|
||||
slot("c", { type: "abstrakt", rationale: "" }),
|
||||
]),
|
||||
mkBank([]),
|
||||
);
|
||||
assert.equal(res.verdict, "BLOCK");
|
||||
assert.equal(res.problems.length, 3);
|
||||
});
|
||||
});
|
||||
130
scripts/specifics-bank/tests/kilder.test.ts
Normal file
130
scripts/specifics-bank/tests/kilder.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { renderKilder } from "../src/kilder.js";
|
||||
import type { LivedSpecifics, Slot } from "../src/binding.js";
|
||||
import type { Bank, Specific } from "../src/types.js";
|
||||
|
||||
function mkSpecific(over: Partial<Specific> & { id: string }): Specific {
|
||||
return {
|
||||
id: over.id,
|
||||
type: over.type ?? "other",
|
||||
content: over.content ?? "x",
|
||||
topicTags: over.topicTags ?? ["t"],
|
||||
provenance: over.provenance ?? { capturedAt: "2026-06-20", source: "test" },
|
||||
verification: over.verification ?? "n/a",
|
||||
status: over.status ?? "active",
|
||||
};
|
||||
}
|
||||
|
||||
const mkBank = (specifics: Specific[]): Bank => ({ schemaVersion: 1, specifics });
|
||||
const ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" });
|
||||
|
||||
describe("renderKilder — the NN-kilder.md artifact", () => {
|
||||
test("golden render of a mixed edition (specific-number-unverified + abstrakt + ekstern + unresolved)", () => {
|
||||
const bank = mkBank([
|
||||
mkSpecific({
|
||||
id: "num1",
|
||||
type: "number",
|
||||
verification: "unverified",
|
||||
content: "Sparte to dager i uka.",
|
||||
topicTags: ["saksbehandling", "tid"],
|
||||
provenance: { capturedAt: "2026-06-19", source: "seres/05" },
|
||||
}),
|
||||
]);
|
||||
const livedSpecifics = ls([
|
||||
{
|
||||
slotId: "kp1",
|
||||
kind: "key-point",
|
||||
label: "Saksbehandling ble raskere",
|
||||
binding: { type: "specific", specificId: "num1" },
|
||||
},
|
||||
{
|
||||
slotId: "kp2",
|
||||
kind: "key-point",
|
||||
label: "Verktøyet bærer faget",
|
||||
binding: { type: "abstrakt", rationale: "Poenget er prinsippet, ikke et enkelttilfelle." },
|
||||
},
|
||||
{
|
||||
slotId: "kp3",
|
||||
kind: "key-point",
|
||||
label: "LinkedIn ranker på relevans",
|
||||
binding: { type: "ekstern" },
|
||||
},
|
||||
{
|
||||
slotId: "kp4",
|
||||
kind: "key-point",
|
||||
label: "Noe uavklart",
|
||||
binding: { type: "unresolved" },
|
||||
},
|
||||
]);
|
||||
|
||||
const expected = `# Kilder — Maskinrommet (05)
|
||||
|
||||
> Auto-generert fra edition-state \`livedSpecifics\` + specifics-bank. Per lastbærende påstand: hva backer den.
|
||||
> Levd materiale er KTGs egne ord, aldri AI-oppdiktet (retning §3). Tall er uverifiserte til fakta-sjekket (regel 6/7).
|
||||
|
||||
## Lastbærende påstander
|
||||
|
||||
### kp1 · Saksbehandling ble raskere
|
||||
- **Binding:** levd materiale (number) · \`num1\` · uverifisert ⚠
|
||||
- **Materiale:** «Sparte to dager i uka.»
|
||||
- **Opphav:** seres/05, fanget 2026-06-19
|
||||
- **Tema-tagger:** saksbehandling, tid
|
||||
|
||||
### kp2 · Verktøyet bærer faget
|
||||
- **Binding:** abstrakt (med vilje) — Poenget er prinsippet, ikke et enkelttilfelle.
|
||||
|
||||
### kp3 · LinkedIn ranker på relevans
|
||||
- **Binding:** ekstern kilde — (fylles av research, Step 2)
|
||||
|
||||
### kp4 · Noe uavklart
|
||||
- **Binding:** ⛔ uavklart — må bindes før skjelett-gaten (vaghet avvises)
|
||||
|
||||
## Dekning
|
||||
- Lastbærende slots: 4
|
||||
- Levd materiale: 1
|
||||
- Abstrakt (escape): 1
|
||||
- Ekstern: 1
|
||||
- Uavklart (BLOCK): 1
|
||||
|
||||
Tall som må verifiseres (regel 6/7): kp1
|
||||
`;
|
||||
|
||||
assert.equal(renderKilder({ articleId: "05", title: "Maskinrommet", livedSpecifics, bank }), expected);
|
||||
});
|
||||
|
||||
test("a verified named-case renders ✓ and no number-verification line", () => {
|
||||
const bank = mkBank([
|
||||
mkSpecific({ id: "c1", type: "named-case", verification: "verified", content: "Etat X." }),
|
||||
]);
|
||||
const out = renderKilder({
|
||||
articleId: "01",
|
||||
title: "T",
|
||||
livedSpecifics: ls([
|
||||
{ slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "c1" } },
|
||||
]),
|
||||
bank,
|
||||
});
|
||||
assert.match(out, /levd materiale \(named-case\) · `c1` · verifisert ✓/);
|
||||
assert.match(out, /Tall som må verifiseres \(regel 6\/7\): ingen/);
|
||||
});
|
||||
|
||||
test("a dangling specific id renders an explicit dangling note", () => {
|
||||
const out = renderKilder({
|
||||
articleId: "01",
|
||||
title: "T",
|
||||
livedSpecifics: ls([
|
||||
{ slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "ghost" } },
|
||||
]),
|
||||
bank: mkBank([]),
|
||||
});
|
||||
assert.match(out, /⛔ levd materiale — id `ghost` mangler i banken \(dangling\)/);
|
||||
});
|
||||
|
||||
test("an empty slot-map renders the 'ingen slots' placeholder, not an empty section", () => {
|
||||
const out = renderKilder({ articleId: "01", title: "T", livedSpecifics: ls([]), bank: mkBank([]) });
|
||||
assert.match(out, /_Ingen lastbærende slots bundet ennå\._/);
|
||||
assert.match(out, /- Lastbærende slots: 0/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue