feat(linkedin-studio): N11 — serie-destillat + skjelett-sjekk + specifics-bank bruks-logging [skip-docs]
Every long-form gate agent sees ONE edition, so «the reader has heard this
before» was structurally invisible — and the specifics-bank dedupe actively
encouraged re-surfacing the same material. At series cadence that is the
fastest-growing defect class. N11 makes it visible BEFORE the skeleton is
approved, at two grains:
- Series grain — new scripts/editions package: each locked edition's spent
anecdotes/arguments/hooks are written to <serie>/linkedin/series-distillate.json
at Step 8 lock (distil-append), and the next skeleton is checked against it at
Step 2.5 (distil-check) with the finding folded into the annotation gate the
operator already reads. Advisory, never blocking: a deliberate callback is a
legitimate move, an unnoticed retread is not.
- Material grain — specifics-bank usedIn log: record-usage stamps «used in
edition NN» on the specifics an edition actually consumed (read from the bound
slot-map, so abstrakt/ekstern stamp nothing). At lock, so it means published;
idempotent under a pivot re-lock. Additive-optional, schema stays v1.
Placement deviates from the plan text (${DATA}) after premise-verification:
per-series state belongs in the series root beside edition-state.json, where
Step 0 already resolves the path and no slug→path map is needed. Operator
approved. The distillate module also lands in scripts/editions now rather than
at N12, since the AC required a testable roundtrip and N12 planned that package
anyway — N12 extends it instead of creating it.
Similarity is character-trigram Jaccard, not word overlap: the plugin is
language-general and inflection (migrere/migreringen) breaks word tokens.
Calibrated on real paraphrase pairs — retellings 0.44-0.55, unrelated 0.04-0.06,
same-topic-different-story 0.24 — so the default threshold sits in the gap at
0.40. Word-Jaccard scored a shortened hook paraphrase at 0.11.
TDD (Iron Law): tests written first and verified red before implementation, for
both the new package and the bank logging.
Suites: editions 27/0 (new suite line + guard, floor 27) · specifics-bank
28 -> 45 · test-runner 163 -> 173 (Section 16r: 9 unconditional greps +
non-vacuity self-test; anti-erosion floor 146 -> 155) · trends 300/0 ·
brain 134/0 · hooks 140/0 · tests 35/0 · render 60/0. tsc --noEmit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
parent
08106317db
commit
b54e450c3e
20 changed files with 2152 additions and 19 deletions
75
scripts/editions/README.md
Normal file
75
scripts/editions/README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# editions — series-level memory for long-form (N11, serie-nivå-vern)
|
||||
|
||||
Every long-form gate agent sees **one** edition. At two editions a week the fastest-
|
||||
growing defect class is not a bad edition — it is a **repeated** one: the anecdote, the
|
||||
argument or the hook the reader already met three editions ago. That failure is
|
||||
structurally invisible today, and the specifics-bank dedupe actively *encourages*
|
||||
re-surfacing the same material.
|
||||
|
||||
So each locked edition leaves behind a **distillate** of the narrative units it spent,
|
||||
and the Step 2.5 skeleton gate checks the next skeleton against it — **before prose
|
||||
exists**, the only point where changing course is still cheap.
|
||||
|
||||
## Where the distillate lives
|
||||
|
||||
`<serie>/linkedin/series-distillate.json` — beside `edition-state.json` in the **series
|
||||
root**, not in the per-user data dir. It is series-scoped state, the series folder is
|
||||
already where the edition's state lives, and putting it there means it travels with the
|
||||
series and needs no slug→path map.
|
||||
|
||||
Series-scoped is the point: this answers *"has this reader heard it?"*. Cross-series
|
||||
re-use of the operator's raw material is a different grain, tracked by the
|
||||
specifics-bank's `usedIn` log.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd scripts/editions && npm install
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
# Step 8 (lock): fold the edition's spent units into the series distillate
|
||||
node --import tsx src/cli.ts distil-append \
|
||||
--distillate "<serie>/linkedin/series-distillate.json" \
|
||||
--series seres --extract "<abs>/extract.json"
|
||||
|
||||
# Step 2.5 (before prose): check the proposed skeleton against everything published
|
||||
node --import tsx src/cli.ts distil-check \
|
||||
--distillate "<serie>/linkedin/series-distillate.json" \
|
||||
--skeleton "<abs>/skeleton.json" [--threshold 0.4] [--json]
|
||||
```
|
||||
|
||||
`extract.json` = `{editionId, title, lockedAt, anecdotes[], arguments[], hooks[]}` — the
|
||||
AI extract from the locked edition. `skeleton.json` = `{anecdotes?, arguments?, hooks?}`.
|
||||
|
||||
Exit codes: `0` on success — **including a REUSE verdict**. The check is advisory by
|
||||
design (it reports into the annotation gate the operator already reads at Step 2.5),
|
||||
unlike the binding gate's BLOCK. `2` on usage error.
|
||||
|
||||
## Model
|
||||
|
||||
- **Extraction is AI, comparison is code.** The command layer distils the units from the
|
||||
locked prose; the store, the similarity and the verdict are plain deterministic code —
|
||||
no model in the loop, no run-to-run variance.
|
||||
- **Similarity = character-trigram Jaccard**, not word overlap. The plugin is
|
||||
language-general and the languages it is used in are inflection-heavy: *migrere* /
|
||||
*migreringen* are the same unit to a reader but different word tokens. Calibrated
|
||||
against real paraphrase pairs (see `tests/distillate.test.ts`): retellings score
|
||||
0.44–0.55, unrelated material 0.04–0.06, same-topic-different-story 0.24 — so the
|
||||
default threshold sits in the gap at **0.40**. Word-Jaccard scored a shortened hook
|
||||
paraphrase at 0.11 and would have let it through.
|
||||
- **Units are compared within their kind.** An argument re-used as a hook is a new move,
|
||||
not a retread.
|
||||
- **A re-locked edition replaces its entry**, never duplicates it — `/linkedin:pivot`
|
||||
re-opens and re-locks the same edition, and the reader still only met it once.
|
||||
- **A missing distillate is not an error.** A series' first edition compares against
|
||||
nothing and is CLEAR.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # node:test, deterministic (callers supply lockedAt)
|
||||
npm run build # tsc — must stay clean
|
||||
```
|
||||
566
scripts/editions/package-lock.json
generated
Normal file
566
scripts/editions/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
{
|
||||
"name": "linkedin-editions",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkedin-editions",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
scripts/editions/package.json
Normal file
16
scripts/editions/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "linkedin-editions",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Series-level memory for long-form editions (N11 — serie-nivå-vern). A per-series distillate of the anecdotes, arguments and hooks each locked edition already spent, so the Step 2.5 skeleton gate can surface «the reader has heard this before» BEFORE prose is written. Deterministic store + similarity check; the extraction of narrative units from a locked edition lives in the command/agent layer.",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"start": "node --import tsx src/cli.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
167
scripts/editions/src/cli.ts
Normal file
167
scripts/editions/src/cli.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI for the series distillate (N11 — serie-nivå-vern).
|
||||
*
|
||||
* node --import tsx src/cli.ts distil-append --distillate <path> --series <slug> --extract <extract.json>
|
||||
* node --import tsx src/cli.ts distil-check --distillate <path> --skeleton <skeleton.json>
|
||||
* [--threshold <0..1>] [--json]
|
||||
*
|
||||
* `distil-append` runs at Step 8 lock: the command layer writes the AI extract
|
||||
* (the anecdotes/arguments/hooks the edition actually spent) to a JSON file, and
|
||||
* this folds it in deterministically. `distil-check` runs at Step 2.5, before
|
||||
* prose, and reports re-use into the annotation gate.
|
||||
*
|
||||
* Exit code: 0 on success (INCLUDING a REUSE verdict — the check is advisory by
|
||||
* design, unlike the binding gate's BLOCK), 2 on usage error.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
DEFAULT_THRESHOLD,
|
||||
appendEntry,
|
||||
checkSkeleton,
|
||||
loadDistillate,
|
||||
saveDistillate,
|
||||
} from "./distillate.js";
|
||||
import type { DistillateEntry, SkeletonCandidate } from "./types.js";
|
||||
|
||||
function parseFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a.startsWith("--")) {
|
||||
const key = a.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
out[key] = "true";
|
||||
} else {
|
||||
out[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function usage(msg: string): never {
|
||||
console.error(`error: ${msg}`);
|
||||
console.error(
|
||||
"usage:\n" +
|
||||
" distil-append --distillate <path> --series <slug> --extract <extract.json>\n" +
|
||||
" distil-check --distillate <path> --skeleton <skeleton.json> [--threshold <0..1>] [--json]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** A flag that was given a real value (not absent, not a bare boolean flag). */
|
||||
function value(flags: Record<string, string>, key: string): string | undefined {
|
||||
const v = flags[key];
|
||||
return v === undefined || v === "true" ? undefined : v;
|
||||
}
|
||||
|
||||
function readJson(path: string, what: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
usage(`could not read ${what} at ${path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((x): x is string => typeof x === "string" && x.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Validate the AI extract at the edge — a malformed extract must not become a silent empty entry. */
|
||||
function toEntry(raw: unknown): DistillateEntry {
|
||||
const o = (raw ?? {}) as Record<string, unknown>;
|
||||
for (const field of ["editionId", "title", "lockedAt"]) {
|
||||
const v = o[field];
|
||||
if (typeof v !== "string" || v.trim().length === 0) {
|
||||
usage(`extract is missing required string field: ${field}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
editionId: o.editionId as string,
|
||||
title: o.title as string,
|
||||
lockedAt: o.lockedAt as string,
|
||||
anecdotes: stringList(o.anecdotes),
|
||||
arguments: stringList(o.arguments),
|
||||
hooks: stringList(o.hooks),
|
||||
};
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const flags = parseFlags(rest);
|
||||
const distillatePath = value(flags, "distillate");
|
||||
const asJson = flags.json === "true";
|
||||
|
||||
if (command === "distil-append") {
|
||||
if (!distillatePath) usage("distil-append needs --distillate <path>");
|
||||
const series = value(flags, "series");
|
||||
if (!series) usage("distil-append needs --series <slug>");
|
||||
const extractPath = value(flags, "extract");
|
||||
if (!extractPath) usage("distil-append needs --extract <extract.json>");
|
||||
|
||||
const entry = toEntry(readJson(extractPath, "extract"));
|
||||
const res = appendEntry(loadDistillate(distillatePath, series), entry);
|
||||
saveDistillate(distillatePath, res.distillate);
|
||||
|
||||
const units = entry.anecdotes.length + entry.arguments.length + entry.hooks.length;
|
||||
console.log(
|
||||
`${res.replaced ? "Replaced" : "Appended"} edition ${entry.editionId} (${units} narrative unit(s)) ` +
|
||||
`in ${distillatePath} — ${res.distillate.editions.length} edition(s) in the series distillate`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "distil-check") {
|
||||
if (!distillatePath) usage("distil-check needs --distillate <path>");
|
||||
const skeletonPath = value(flags, "skeleton");
|
||||
if (!skeletonPath) usage("distil-check needs --skeleton <skeleton.json>");
|
||||
|
||||
const rawThreshold = value(flags, "threshold");
|
||||
let threshold = DEFAULT_THRESHOLD;
|
||||
if (rawThreshold !== undefined) {
|
||||
threshold = Number(rawThreshold);
|
||||
if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) {
|
||||
usage("--threshold must be a number in (0, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = (readJson(skeletonPath, "skeleton") ?? {}) as SkeletonCandidate;
|
||||
const report = checkSkeleton(loadDistillate(distillatePath, value(flags, "series") ?? ""), candidate, {
|
||||
threshold,
|
||||
});
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (report.verdict === "CLEAR") {
|
||||
console.log(
|
||||
`CLEAR — nothing in this skeleton resembles the ${report.comparedAgainst} locked edition(s) ` +
|
||||
`(threshold ${report.threshold}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`REUSE — ${report.hits.length} unit(s) resemble earlier editions (threshold ${report.threshold}, ` +
|
||||
`${report.comparedAgainst} edition(s) checked):`,
|
||||
);
|
||||
for (const hit of report.hits) {
|
||||
console.log(`\n · ${hit.kind} — ${(hit.score * 100).toFixed(0)}% like edition ${hit.editionId}`);
|
||||
console.log(` new: ${hit.candidate}`);
|
||||
console.log(` then: ${hit.matched}`);
|
||||
}
|
||||
console.log("\nAdvisory: a deliberate callback is fine — an unnoticed retread is not.");
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
main();
|
||||
179
scripts/editions/src/distillate.ts
Normal file
179
scripts/editions/src/distillate.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Deterministic store + reuse check for the series distillate (N11 — C-5).
|
||||
*
|
||||
* Pure where it matters: normalization, similarity and the skeleton check are
|
||||
* side-effect-free and fully testable. Only load/save touch the filesystem.
|
||||
*
|
||||
* Similarity is CHARACTER TRIGRAM Jaccard, not word overlap. The plugin is
|
||||
* language-general, and the languages it is actually used in are inflection-
|
||||
* heavy: "migrere"/"migreringen" and "pipeline"/"pipelinen" are the same unit to
|
||||
* a reader but different word tokens. Calibrated against real paraphrase pairs
|
||||
* (see tests/distillate.test.ts): retellings score 0.44–0.55, unrelated material
|
||||
* 0.04–0.06, and same-topic-different-story 0.24 — so the default threshold sits
|
||||
* in the gap at 0.40. Word-Jaccard scored a shortened hook paraphrase at 0.11
|
||||
* and would have let it through.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { SCHEMA_VERSION } from "./types.js";
|
||||
import type {
|
||||
DistillateEntry,
|
||||
NarrativeKind,
|
||||
ReuseHit,
|
||||
ReuseReport,
|
||||
SeriesDistillate,
|
||||
SkeletonCandidate,
|
||||
} from "./types.js";
|
||||
|
||||
export { SCHEMA_VERSION } from "./types.js";
|
||||
|
||||
/** Similarity at or above this counts as re-use. Empirically calibrated — see the header. */
|
||||
export const DEFAULT_THRESHOLD = 0.4;
|
||||
|
||||
const KINDS: NarrativeKind[] = ["anecdote", "argument", "hook"];
|
||||
|
||||
/** Lowercase, strip everything that is not a letter or digit, collapse whitespace. */
|
||||
export function normalizeUnit(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Character trigrams over the normalized text, space-padded so short units still overlap. */
|
||||
function trigrams(text: string): Set<string> {
|
||||
const normalized = normalizeUnit(text);
|
||||
if (normalized.length === 0) return new Set();
|
||||
const padded = ` ${normalized} `;
|
||||
const out = new Set<string>();
|
||||
for (let i = 0; i + 3 <= padded.length; i++) out.add(padded.slice(i, i + 3));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Jaccard over character trigrams: 1 = identical after normalization, 0 = nothing shared. */
|
||||
export function similarity(a: string, b: string): number {
|
||||
const A = trigrams(a);
|
||||
const B = trigrams(b);
|
||||
if (A.size === 0 || B.size === 0) return 0;
|
||||
let intersection = 0;
|
||||
for (const gram of A) if (B.has(gram)) intersection++;
|
||||
return intersection / (A.size + B.size - intersection);
|
||||
}
|
||||
|
||||
export function emptyDistillate(series: string): SeriesDistillate {
|
||||
return { schemaVersion: SCHEMA_VERSION, series, editions: [] };
|
||||
}
|
||||
|
||||
/** Read the distillate; a missing file is an empty one (a series' first edition is not an error). */
|
||||
export function loadDistillate(path: string, series: string): SeriesDistillate {
|
||||
if (!existsSync(path)) return emptyDistillate(series);
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<SeriesDistillate>;
|
||||
return {
|
||||
schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION,
|
||||
series: parsed.series ?? series,
|
||||
editions: Array.isArray(parsed.editions) ? parsed.editions : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Write as pretty JSON with a trailing newline, creating the parent dir if needed. */
|
||||
export function saveDistillate(path: string, distillate: SeriesDistillate): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(distillate, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
distillate: SeriesDistillate;
|
||||
/** true iff the edition was new. */
|
||||
appended: boolean;
|
||||
/** true iff an existing edition was overwritten in place (a pivot re-lock). */
|
||||
replaced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one locked edition into the distillate. An edition already present is
|
||||
* REPLACED in place, never duplicated: `/linkedin:pivot` re-opens and re-locks
|
||||
* the same edition, and the reader still only ever met it once.
|
||||
*/
|
||||
export function appendEntry(distillate: SeriesDistillate, entry: DistillateEntry): AppendResult {
|
||||
const stored: DistillateEntry = {
|
||||
editionId: entry.editionId,
|
||||
title: entry.title,
|
||||
lockedAt: entry.lockedAt,
|
||||
anecdotes: [...entry.anecdotes],
|
||||
arguments: [...entry.arguments],
|
||||
hooks: [...entry.hooks],
|
||||
};
|
||||
const at = distillate.editions.findIndex((e) => e.editionId === entry.editionId);
|
||||
if (at >= 0) {
|
||||
distillate.editions[at] = stored;
|
||||
return { distillate, appended: false, replaced: true };
|
||||
}
|
||||
distillate.editions.push(stored);
|
||||
return { distillate, appended: true, replaced: false };
|
||||
}
|
||||
|
||||
function unitsOf(entry: DistillateEntry, kind: NarrativeKind): string[] {
|
||||
if (kind === "anecdote") return entry.anecdotes;
|
||||
if (kind === "argument") return entry.arguments;
|
||||
return entry.hooks;
|
||||
}
|
||||
|
||||
function candidateUnits(candidate: SkeletonCandidate, kind: NarrativeKind): string[] {
|
||||
if (kind === "anecdote") return candidate.anecdotes ?? [];
|
||||
if (kind === "argument") return candidate.arguments ?? [];
|
||||
return candidate.hooks ?? [];
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
/** Override the calibrated default when a series wants to be stricter or looser. */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a proposed skeleton against everything the series already published.
|
||||
* Units are compared WITHIN their kind — an argument re-used as a hook is a new
|
||||
* move, not a retread. Advisory: the verdict feeds the Step 2.5 annotation gate.
|
||||
*/
|
||||
export function checkSkeleton(
|
||||
distillate: SeriesDistillate,
|
||||
candidate: SkeletonCandidate,
|
||||
options: CheckOptions = {},
|
||||
): ReuseReport {
|
||||
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
||||
const hits: ReuseHit[] = [];
|
||||
|
||||
for (const kind of KINDS) {
|
||||
for (const proposed of candidateUnits(candidate, kind)) {
|
||||
for (const edition of distillate.editions) {
|
||||
for (const published of unitsOf(edition, kind)) {
|
||||
const score = similarity(proposed, published);
|
||||
if (score >= threshold) {
|
||||
hits.push({ kind, candidate: proposed, matched: published, editionId: edition.editionId, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hits.sort((a, b) => b.score - a.score);
|
||||
|
||||
return {
|
||||
verdict: hits.length > 0 ? "REUSE" : "CLEAR",
|
||||
hits,
|
||||
threshold,
|
||||
comparedAgainst: distillate.editions.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The distillate sits beside `edition-state.json` in the series root, not in the
|
||||
* per-user data dir: it is series-scoped state, and the series folder is where
|
||||
* the edition's state already lives (`<serie>/linkedin/`), so it travels with
|
||||
* the series and needs no slug→path map.
|
||||
*/
|
||||
export function defaultDistillatePath(seriesRoot: string): string {
|
||||
return join(seriesRoot, "linkedin", "series-distillate.json");
|
||||
}
|
||||
83
scripts/editions/src/types.ts
Normal file
83
scripts/editions/src/types.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Types for the series distillate (N11 — serie-nivå-vern / C-5).
|
||||
*
|
||||
* The gap this closes: every long-form gate agent sees ONE edition. At two
|
||||
* editions a week the fastest-growing defect class is not a bad edition — it is
|
||||
* a *repeated* one: the anecdote, the argument or the hook the reader already
|
||||
* met three editions ago. Structurally invisible today, and the specifics-bank
|
||||
* dedupe actively encourages re-surfacing the same material.
|
||||
*
|
||||
* So each locked edition leaves behind a distillate of the narrative units it
|
||||
* spent, and the Step 2.5 skeleton gate checks the NEXT skeleton against it —
|
||||
* before prose exists, which is the only point where changing course is cheap.
|
||||
*
|
||||
* Deterministic by design: the extraction of units from a locked edition is an
|
||||
* AI job (the command layer), but the store, the comparison and the verdict are
|
||||
* plain code — no model in the loop, no run-to-run variance.
|
||||
*
|
||||
* Scope note: this file is SERIES-scoped memory ("has this reader heard it?").
|
||||
* Cross-series re-use of the operator's raw material is the specifics-bank's
|
||||
* `usedIn` log — a different grain, deliberately a different store.
|
||||
*/
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
/** The three narrative units a reader actually recognizes across editions. */
|
||||
export type NarrativeKind = "anecdote" | "argument" | "hook";
|
||||
|
||||
/** What one locked edition spent. */
|
||||
export interface DistillateEntry {
|
||||
/** Edition id within the series, e.g. "05". */
|
||||
editionId: string;
|
||||
title: string;
|
||||
/** ISO date the edition was locked. Supplied by the caller (CLI edge) — no clock in the pure core. */
|
||||
lockedAt: string;
|
||||
/** Stories told, in the operator's own phrasing. */
|
||||
anecdotes: string[];
|
||||
/** Claims argued. */
|
||||
arguments: string[];
|
||||
/** Opening moves used. */
|
||||
hooks: string[];
|
||||
}
|
||||
|
||||
export interface SeriesDistillate {
|
||||
schemaVersion: number;
|
||||
/** Series slug — the folder the editions live in. */
|
||||
series: string;
|
||||
editions: DistillateEntry[];
|
||||
}
|
||||
|
||||
/** The units a NEW skeleton proposes, checked before prose (Step 2.5). */
|
||||
export interface SkeletonCandidate {
|
||||
anecdotes?: string[];
|
||||
arguments?: string[];
|
||||
hooks?: string[];
|
||||
}
|
||||
|
||||
/** One proposed unit that resembles something the series already published. */
|
||||
export interface ReuseHit {
|
||||
kind: NarrativeKind;
|
||||
/** The proposed text. */
|
||||
candidate: string;
|
||||
/** The already-published text it resembles. */
|
||||
matched: string;
|
||||
/** Which edition spent it. */
|
||||
editionId: string;
|
||||
/** 0..1 similarity; 1 = verbatim. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
export type ReuseVerdict = "CLEAR" | "REUSE";
|
||||
|
||||
/**
|
||||
* Advisory by design — this reports into the annotation gate the operator
|
||||
* already reads at Step 2.5; it never blocks. Re-use is sometimes the right
|
||||
* call (a deliberate callback); silence about it never is.
|
||||
*/
|
||||
export interface ReuseReport {
|
||||
verdict: ReuseVerdict;
|
||||
hits: ReuseHit[];
|
||||
threshold: number;
|
||||
/** How many locked editions the skeleton was compared against. */
|
||||
comparedAgainst: number;
|
||||
}
|
||||
152
scripts/editions/tests/cli.test.ts
Normal file
152
scripts/editions/tests/cli.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Resolve the package root (scripts/editions) so the subprocess `src/cli.ts` path +
|
||||
// the `tsx` loader resolve regardless of the runner's cwd.
|
||||
const editionsDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
function run(args: string[]): { status: number | null; stdout: string; stderr: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
|
||||
encoding: "utf8",
|
||||
cwd: editionsDir,
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
||||
}
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "editions-cli-"));
|
||||
|
||||
const EXTRACT = {
|
||||
editionId: "05",
|
||||
title: "Da pipelinen brøt sammen",
|
||||
lockedAt: "2026-07-20",
|
||||
anecdotes: ["Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata."],
|
||||
arguments: ["Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år."],
|
||||
hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."],
|
||||
};
|
||||
|
||||
describe("editions CLI — distil-append / distil-check round-trip (N11 AC)", () => {
|
||||
test("append -> check round-trips through a real file and flags the reuse", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const distillate = join(dir, "linkedin", "series-distillate.json");
|
||||
const extract = join(dir, "extract.json");
|
||||
const skeleton = join(dir, "skeleton.json");
|
||||
writeFileSync(extract, JSON.stringify(EXTRACT), "utf8");
|
||||
|
||||
// 1. lock writes the distillate (file did not exist)
|
||||
const appended = run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
assert.equal(appended.status, 0, appended.stderr);
|
||||
assert.ok(existsSync(distillate), "distil-append must create the distillate file");
|
||||
const onDisk = JSON.parse(readFileSync(distillate, "utf8"));
|
||||
assert.equal(onDisk.series, "seres");
|
||||
assert.equal(onDisk.editions.length, 1);
|
||||
assert.equal(onDisk.editions[0].editionId, "05");
|
||||
|
||||
// 2. a later skeleton reusing the same hook is flagged, with attribution
|
||||
writeFileSync(skeleton, JSON.stringify({ hooks: [EXTRACT.hooks[0]] }), "utf8");
|
||||
const checked = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton, "--json"]);
|
||||
assert.equal(checked.status, 0, checked.stderr);
|
||||
const report = JSON.parse(checked.stdout);
|
||||
assert.equal(report.verdict, "REUSE");
|
||||
assert.equal(report.hits[0].editionId, "05");
|
||||
assert.equal(report.hits[0].kind, "hook");
|
||||
|
||||
// 3. fresh material on the same distillate is CLEAR
|
||||
writeFileSync(
|
||||
skeleton,
|
||||
JSON.stringify({ hooks: ["Innkjøpsavdelingen krevde en garanti vi ikke kunne gi uten revisjon."] }),
|
||||
"utf8",
|
||||
);
|
||||
const clear = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton, "--json"]);
|
||||
assert.equal(clear.status, 0, clear.stderr);
|
||||
assert.equal(JSON.parse(clear.stdout).verdict, "CLEAR");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("re-appending the same edition replaces it — a pivot re-lock does not duplicate", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const distillate = join(dir, "series-distillate.json");
|
||||
const extract = join(dir, "extract.json");
|
||||
writeFileSync(extract, JSON.stringify(EXTRACT), "utf8");
|
||||
run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
|
||||
writeFileSync(extract, JSON.stringify({ ...EXTRACT, title: "Revidert" }), "utf8");
|
||||
const again = run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
assert.equal(again.status, 0, again.stderr);
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(distillate, "utf8"));
|
||||
assert.equal(onDisk.editions.length, 1);
|
||||
assert.equal(onDisk.editions[0].title, "Revidert");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("distil-check against a distillate that does not exist yet is CLEAR, not an error", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const skeleton = join(dir, "skeleton.json");
|
||||
writeFileSync(skeleton, JSON.stringify({ hooks: ["første utgave i serien"] }), "utf8");
|
||||
const res = run([
|
||||
"distil-check",
|
||||
"--distillate",
|
||||
join(dir, "absent.json"),
|
||||
"--skeleton",
|
||||
skeleton,
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
const report = JSON.parse(res.stdout);
|
||||
assert.equal(report.verdict, "CLEAR");
|
||||
assert.equal(report.comparedAgainst, 0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("human-readable output names the reused unit and its edition", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const distillate = join(dir, "d.json");
|
||||
const extract = join(dir, "extract.json");
|
||||
const skeleton = join(dir, "skeleton.json");
|
||||
writeFileSync(extract, JSON.stringify(EXTRACT), "utf8");
|
||||
run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]);
|
||||
writeFileSync(skeleton, JSON.stringify({ hooks: [EXTRACT.hooks[0]] }), "utf8");
|
||||
|
||||
const res = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(res.stdout, /REUSE/);
|
||||
assert.match(res.stdout, /05/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bad invocations exit 2 with usage on stderr", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
assert.equal(run(["distil-append", "--series", "seres"]).status, 2); // no --extract/--distillate
|
||||
assert.equal(run(["distil-check"]).status, 2); // no --skeleton
|
||||
assert.equal(run(["nonsense"]).status, 2);
|
||||
assert.equal(run([]).status, 2);
|
||||
|
||||
// an extract missing required fields is a usage error, not a silent empty entry
|
||||
const bad = join(dir, "bad.json");
|
||||
writeFileSync(bad, JSON.stringify({ title: "no id" }), "utf8");
|
||||
const res = run(["distil-append", "--distillate", join(dir, "d.json"), "--series", "s", "--extract", bad]);
|
||||
assert.equal(res.status, 2);
|
||||
assert.match(res.stderr, /editionId/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
260
scripts/editions/tests/distillate.test.ts
Normal file
260
scripts/editions/tests/distillate.test.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import {
|
||||
DEFAULT_THRESHOLD,
|
||||
appendEntry,
|
||||
checkSkeleton,
|
||||
defaultDistillatePath,
|
||||
emptyDistillate,
|
||||
loadDistillate,
|
||||
normalizeUnit,
|
||||
saveDistillate,
|
||||
similarity,
|
||||
} from "../src/distillate.js";
|
||||
import { SCHEMA_VERSION } from "../src/types.js";
|
||||
import type { DistillateEntry } from "../src/types.js";
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "editions-"));
|
||||
|
||||
const entry = (over: Partial<DistillateEntry> = {}): DistillateEntry => ({
|
||||
editionId: "05",
|
||||
title: "Da pipelinen brøt sammen",
|
||||
lockedAt: "2026-07-20",
|
||||
anecdotes: ["Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata."],
|
||||
arguments: ["Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år."],
|
||||
hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("series distillate — store", () => {
|
||||
test("emptyDistillate carries the schema version, the series and no editions", () => {
|
||||
const d = emptyDistillate("seres");
|
||||
assert.equal(d.schemaVersion, SCHEMA_VERSION);
|
||||
assert.equal(d.series, "seres");
|
||||
assert.deepEqual(d.editions, []);
|
||||
});
|
||||
|
||||
test("loadDistillate on a missing file returns an empty distillate (never throws)", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const d = loadDistillate(join(dir, "nope.json"), "seres");
|
||||
assert.equal(d.series, "seres");
|
||||
assert.deepEqual(d.editions, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("save -> load round-trips an appended edition byte-faithfully", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, "linkedin", "series-distillate.json");
|
||||
const { distillate } = appendEntry(emptyDistillate("seres"), entry());
|
||||
saveDistillate(path, distillate);
|
||||
|
||||
const back = loadDistillate(path, "seres");
|
||||
assert.equal(back.editions.length, 1);
|
||||
assert.deepEqual(back.editions[0], entry());
|
||||
assert.equal(back.schemaVersion, SCHEMA_VERSION);
|
||||
// trailing newline, like every other store in the plugin
|
||||
assert.ok(readFileSync(path, "utf8").endsWith("}\n"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("appendEntry appends a new edition and reports it", () => {
|
||||
const res = appendEntry(emptyDistillate("seres"), entry());
|
||||
assert.equal(res.appended, true);
|
||||
assert.equal(res.replaced, false);
|
||||
assert.equal(res.distillate.editions.length, 1);
|
||||
});
|
||||
|
||||
test("re-locking the same edition REPLACES it, never duplicates (pivot re-lock)", () => {
|
||||
const first = appendEntry(emptyDistillate("seres"), entry());
|
||||
const revised = entry({ title: "Da pipelinen brøt sammen (revidert)" });
|
||||
const second = appendEntry(first.distillate, revised);
|
||||
|
||||
assert.equal(second.appended, false);
|
||||
assert.equal(second.replaced, true);
|
||||
assert.equal(second.distillate.editions.length, 1);
|
||||
assert.equal(second.distillate.editions[0].title, "Da pipelinen brøt sammen (revidert)");
|
||||
});
|
||||
|
||||
test("editions keep insertion order", () => {
|
||||
let d = emptyDistillate("seres");
|
||||
d = appendEntry(d, entry({ editionId: "03" })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "05" })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "04" })).distillate;
|
||||
assert.deepEqual(
|
||||
d.editions.map((e) => e.editionId),
|
||||
["03", "05", "04"],
|
||||
);
|
||||
});
|
||||
|
||||
test("defaultDistillatePath sits beside edition-state in the series root", () => {
|
||||
assert.equal(
|
||||
defaultDistillatePath("/series/seres"),
|
||||
join("/series/seres", "linkedin", "series-distillate.json"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("series distillate — similarity (language-agnostic character trigrams)", () => {
|
||||
test("normalizeUnit lowercases, strips punctuation and collapses whitespace", () => {
|
||||
assert.equal(normalizeUnit(" Hei, DU — da! "), "hei du da");
|
||||
});
|
||||
|
||||
test("identical text scores 1", () => {
|
||||
const s = "Vi målte 40 prosent kortere ledetid etter omleggingen.";
|
||||
assert.equal(similarity(s, s), 1);
|
||||
});
|
||||
|
||||
test("empty input scores 0 instead of dividing by zero", () => {
|
||||
assert.equal(similarity("", "noe tekst"), 0);
|
||||
assert.equal(similarity("noe tekst", " "), 0);
|
||||
});
|
||||
|
||||
test("similarity is symmetric", () => {
|
||||
const a = "Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år.";
|
||||
const b = "Problemet var aldri modellen. Det var fem år med data som ingen hadde ryddet.";
|
||||
assert.equal(similarity(a, b), similarity(b, a));
|
||||
});
|
||||
|
||||
// The calibration set the threshold is derived from. Paraphrase (the same story
|
||||
// retold) must land ABOVE the default; unrelated material and same-topic-different-
|
||||
// story must land BELOW it — inflection-heavy Norwegian included, which is why this
|
||||
// is character trigrams and not word tokens (a shortened hook paraphrase scores
|
||||
// 0.11 on word-Jaccard and would slip through).
|
||||
test("paraphrase of the same story clears the default threshold", () => {
|
||||
const pairs: Array<[string, string]> = [
|
||||
[
|
||||
"Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata.",
|
||||
"Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata.",
|
||||
],
|
||||
[
|
||||
"Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år.",
|
||||
"Problemet var aldri modellen. Det var fem år med data som ingen hadde ryddet.",
|
||||
],
|
||||
[
|
||||
"De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data.",
|
||||
"AI-prosjekter dør sjelden av modellen. De dør av dataene.",
|
||||
],
|
||||
];
|
||||
for (const [a, b] of pairs) {
|
||||
assert.ok(
|
||||
similarity(a, b) >= DEFAULT_THRESHOLD,
|
||||
`expected paraphrase >= ${DEFAULT_THRESHOLD}, got ${similarity(a, b).toFixed(3)}: ${a}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("unrelated material and same-topic-different-story stay below the threshold", () => {
|
||||
const pairs: Array<[string, string]> = [
|
||||
[
|
||||
"Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata.",
|
||||
"Innkjøpsavdelingen krevde en leverandørgaranti vi ikke kunne gi uten en revisjon.",
|
||||
],
|
||||
[
|
||||
"De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data.",
|
||||
"Vi har hatt en fast ukentlig gjennomgang av porteføljen siden januar.",
|
||||
],
|
||||
[
|
||||
// same topic, different measured claim — a NEW edition, not a retread
|
||||
"Vi målte 40 prosent kortere ledetid etter omleggingen.",
|
||||
"Vi målte 12 prosent høyere treffrate etter at vi byttet embedding-modell.",
|
||||
],
|
||||
];
|
||||
for (const [a, b] of pairs) {
|
||||
assert.ok(
|
||||
similarity(a, b) < DEFAULT_THRESHOLD,
|
||||
`expected non-reuse < ${DEFAULT_THRESHOLD}, got ${similarity(a, b).toFixed(3)}: ${a}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("series distillate — skeleton check (Step 2.5)", () => {
|
||||
const distillate = appendEntry(emptyDistillate("seres"), entry()).distillate;
|
||||
|
||||
test("an empty distillate is always CLEAR (first edition in a series)", () => {
|
||||
const r = checkSkeleton(emptyDistillate("seres"), { hooks: ["hva som helst"] });
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
assert.deepEqual(r.hits, []);
|
||||
assert.equal(r.comparedAgainst, 0);
|
||||
});
|
||||
|
||||
test("fresh material is CLEAR", () => {
|
||||
const r = checkSkeleton(distillate, {
|
||||
anecdotes: ["Innkjøpsavdelingen krevde en leverandørgaranti vi ikke kunne gi uten en revisjon."],
|
||||
});
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
assert.deepEqual(r.hits, []);
|
||||
});
|
||||
|
||||
test("verbatim reuse is REUSE and names the edition it came from", () => {
|
||||
const r = checkSkeleton(distillate, { hooks: [entry().hooks[0]] });
|
||||
assert.equal(r.verdict, "REUSE");
|
||||
assert.equal(r.hits.length, 1);
|
||||
assert.equal(r.hits[0].kind, "hook");
|
||||
assert.equal(r.hits[0].editionId, "05");
|
||||
assert.equal(r.hits[0].score, 1);
|
||||
assert.equal(r.hits[0].matched, entry().hooks[0]);
|
||||
});
|
||||
|
||||
test("paraphrased reuse is caught, not just verbatim", () => {
|
||||
const r = checkSkeleton(distillate, {
|
||||
anecdotes: ["Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata."],
|
||||
});
|
||||
assert.equal(r.verdict, "REUSE");
|
||||
assert.equal(r.hits[0].kind, "anecdote");
|
||||
assert.ok(r.hits[0].score >= DEFAULT_THRESHOLD);
|
||||
});
|
||||
|
||||
test("kinds are compared within kind — a hook is not matched against an anecdote", () => {
|
||||
// the anecdote text, offered as a hook: the anecdote bucket must not match it
|
||||
const r = checkSkeleton(distillate, { hooks: [entry().anecdotes[0]] });
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
});
|
||||
|
||||
test("hits are sorted by score descending", () => {
|
||||
let d = emptyDistillate("seres");
|
||||
d = appendEntry(d, entry({ editionId: "03", hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."] })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "04", hooks: ["AI-prosjekter dør sjelden av modellen. De dør av dataene."], anecdotes: [], arguments: [] })).distillate;
|
||||
|
||||
const r = checkSkeleton(d, { hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."] });
|
||||
assert.equal(r.verdict, "REUSE");
|
||||
assert.ok(r.hits.length >= 2);
|
||||
for (let i = 1; i < r.hits.length; i++) {
|
||||
assert.ok(r.hits[i - 1].score >= r.hits[i].score, "hits must be sorted by score desc");
|
||||
}
|
||||
assert.equal(r.hits[0].score, 1);
|
||||
});
|
||||
|
||||
test("the threshold is configurable and reported back", () => {
|
||||
const strict = checkSkeleton(
|
||||
distillate,
|
||||
{ anecdotes: ["Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata."] },
|
||||
{ threshold: 0.95 },
|
||||
);
|
||||
assert.equal(strict.verdict, "CLEAR");
|
||||
assert.equal(strict.threshold, 0.95);
|
||||
});
|
||||
|
||||
test("comparedAgainst reports how many editions the skeleton was checked against", () => {
|
||||
let d = emptyDistillate("seres");
|
||||
d = appendEntry(d, entry({ editionId: "03" })).distillate;
|
||||
d = appendEntry(d, entry({ editionId: "04" })).distillate;
|
||||
assert.equal(checkSkeleton(d, { hooks: ["noe helt annet om innkjøp og garantier"] }).comparedAgainst, 2);
|
||||
});
|
||||
|
||||
test("an absent bucket on the candidate is not an error", () => {
|
||||
const r = checkSkeleton(distillate, {});
|
||||
assert.equal(r.verdict, "CLEAR");
|
||||
assert.deepEqual(r.hits, []);
|
||||
});
|
||||
});
|
||||
16
scripts/editions/tsconfig.json
Normal file
16
scripts/editions/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "tests"]
|
||||
}
|
||||
|
|
@ -142,6 +142,56 @@ export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] {
|
|||
return hits;
|
||||
}
|
||||
|
||||
/** What a caller supplies to recordUsage. */
|
||||
export interface UsageInput {
|
||||
/** Bank ids the edition actually consumed — see `boundSpecificIds` in binding.ts. */
|
||||
ids: string[];
|
||||
/** The edition the material was published in, e.g. "seres/05". */
|
||||
editionId: string;
|
||||
}
|
||||
|
||||
export interface UsageResult {
|
||||
bank: Bank;
|
||||
/** ids that gained this edition now. */
|
||||
recorded: string[];
|
||||
/** ids already carrying it — a re-lock after a pivot is idempotent. */
|
||||
alreadyRecorded: string[];
|
||||
/** ids absent from the bank; the caller decides how loud to be. */
|
||||
unknown: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp "used in edition NN" on the specifics an edition consumed. Called at
|
||||
* LOCK (Step 8) rather than at binding (Step 2.5), so an abandoned draft never
|
||||
* pollutes the history — `usedIn` means published, not considered.
|
||||
* Archived material still records: the use is historical fact.
|
||||
*/
|
||||
export function recordUsage(bank: Bank, input: UsageInput): UsageResult {
|
||||
const editionId = input.editionId.trim();
|
||||
if (editionId.length === 0) {
|
||||
throw new Error("recordUsage: editionId must be non-blank — an unattributable use is worse than none");
|
||||
}
|
||||
const byId = new Map<string, Specific>(bank.specifics.map((s) => [s.id, s]));
|
||||
const recorded: string[] = [];
|
||||
const alreadyRecorded: string[] = [];
|
||||
const unknown: string[] = [];
|
||||
for (const id of input.ids) {
|
||||
const specific = byId.get(id);
|
||||
if (!specific) {
|
||||
unknown.push(id);
|
||||
continue;
|
||||
}
|
||||
const usedIn = specific.usedIn ?? [];
|
||||
if (usedIn.includes(editionId)) {
|
||||
alreadyRecorded.push(id);
|
||||
continue;
|
||||
}
|
||||
specific.usedIn = [...usedIn, editionId];
|
||||
recorded.push(id);
|
||||
}
|
||||
return { bank, recorded, alreadyRecorded, unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bank path under the per-user data dir (M0 data-path convention), so the
|
||||
* bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the
|
||||
|
|
|
|||
|
|
@ -154,3 +154,21 @@ export function validateBinding(ls: LivedSpecifics, bank: Bank): BindingResult {
|
|||
coverage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bank ids an edition's slot-map actually consumed — slot order, deduped.
|
||||
* Pure. This is the input to `recordUsage` at lock: only `specific` bindings
|
||||
* count as consumption (abstrakt/ekstern/unresolved consume no bank material).
|
||||
*/
|
||||
export function boundSpecificIds(ls: LivedSpecifics): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const slot of ls.slots) {
|
||||
if (slot.binding.type !== "specific") continue;
|
||||
const { specificId } = slot.binding;
|
||||
if (seen.has(specificId)) continue;
|
||||
seen.add(specificId);
|
||||
ids.push(specificId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ import {
|
|||
defaultBankPath,
|
||||
loadBank,
|
||||
queryByTopic,
|
||||
recordUsage,
|
||||
saveBank,
|
||||
} from "./bank.js";
|
||||
import { validateBinding } from "./binding.js";
|
||||
import { boundSpecificIds, validateBinding } from "./binding.js";
|
||||
import { renderKilder } from "./kilder.js";
|
||||
import type { LivedSpecifics } from "./binding.js";
|
||||
import type { SpecificType, Verification } from "./types.js";
|
||||
|
|
@ -72,7 +73,8 @@ function usage(msg: string): never {
|
|||
' add --type <t> --content "<text>" --tags <a,b> [--source <s>] [--verification <v>] [--bank <path>]\n' +
|
||||
" list [--bank <path>] [--json]\n" +
|
||||
" validate-binding --edition <edition-state.json> [--article <id>] [--bank <path>] [--json]\n" +
|
||||
" render-kilder --edition <edition-state.json> [--article <id>] [--bank <path>] [--out <path>]",
|
||||
" render-kilder --edition <edition-state.json> [--article <id>] [--bank <path>] [--out <path>]\n" +
|
||||
" record-usage --edition <edition-state.json> --edition-id <slug/NN> [--article <id>] [--bank <path>] [--json]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -213,6 +215,32 @@ function main(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === "record-usage") {
|
||||
if (!flags.edition || flags.edition === "true") usage("record-usage needs --edition <path>");
|
||||
const editionId = flags["edition-id"];
|
||||
if (!editionId || editionId === "true") {
|
||||
usage("record-usage needs --edition-id <slug/NN> — an unattributable use is worse than none");
|
||||
}
|
||||
const edition = loadEdition(flags.edition);
|
||||
const { id, article } = resolveArticle(edition, flags.article);
|
||||
const ids = boundSpecificIds(article.livedSpecifics ?? EMPTY_LS);
|
||||
const res = recordUsage(loadBank(bankPath), { ids, editionId });
|
||||
saveBank(bankPath, res.bank);
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ article: id, editionId, ...res, bank: undefined }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Usage recorded for article ${id} as "${editionId}": ` +
|
||||
`${res.recorded.length} stamped, ${res.alreadyRecorded.length} already logged.`,
|
||||
);
|
||||
if (res.unknown.length > 0) {
|
||||
console.log(` ⚠ ${res.unknown.length} bound id(s) absent from the bank: ${res.unknown.join(", ")}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ export interface Specific {
|
|||
/** Numbers must be fact-checked before a draft asserts them as fact (regel 6/7). */
|
||||
verification: Verification;
|
||||
status: SpecificStatus;
|
||||
/**
|
||||
* Editions this material was actually published in ("seres/05"), appended at
|
||||
* lock. Additive-optional: absent means never used, so existing banks load
|
||||
* unchanged and the schema stays v1. The dedupe in `addSpecific` deliberately
|
||||
* ENCOURAGES re-surfacing the same material; this field is what makes the
|
||||
* re-use visible to the operator at Step 1.5 instead of silent (N11 / C-5).
|
||||
*/
|
||||
usedIn?: string[];
|
||||
}
|
||||
|
||||
export interface Bank {
|
||||
|
|
|
|||
142
scripts/specifics-bank/tests/cli-usage.test.ts
Normal file
142
scripts/specifics-bank/tests/cli-usage.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { addSpecific, emptyBank, specificId, saveBank } from "../src/bank.js";
|
||||
|
||||
// Resolve the package root so the subprocess `src/cli.ts` path + the `tsx` loader
|
||||
// resolve regardless of the runner's cwd.
|
||||
const bankDir = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
function run(args: string[]): { status: number | null; stdout: string; stderr: string } {
|
||||
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
|
||||
encoding: "utf8",
|
||||
cwd: bankDir,
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
|
||||
}
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "specifics-cli-usage-"));
|
||||
|
||||
const CONTENT = "Vi målte 40 prosent kortere ledetid etter omleggingen.";
|
||||
|
||||
/** A bank + an edition-state whose slot-map binds one slot to the bank specific. */
|
||||
function fixture(dir: string): { bankPath: string; editionPath: string; id: string } {
|
||||
const bankPath = join(dir, "specifics-bank.json");
|
||||
const id = specificId(CONTENT);
|
||||
const bank = addSpecific(emptyBank(), {
|
||||
type: "number",
|
||||
content: CONTENT,
|
||||
topicTags: ["ai"],
|
||||
provenance: { capturedAt: "2026-06-20", source: "test" },
|
||||
}).bank;
|
||||
saveBank(bankPath, bank);
|
||||
|
||||
const editionPath = join(dir, "edition-state.json");
|
||||
writeFileSync(
|
||||
editionPath,
|
||||
JSON.stringify({
|
||||
currentArticle: "05",
|
||||
articles: {
|
||||
"05": {
|
||||
title: "Da pipelinen brøt sammen",
|
||||
livedSpecifics: {
|
||||
status: "bound",
|
||||
slots: [
|
||||
{ slotId: "kp1", kind: "key-point", label: "ledetid", binding: { type: "specific", specificId: id } },
|
||||
{ slotId: "kp2", kind: "key-point", label: "annet", binding: { type: "abstrakt", rationale: "bevisst" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return { bankPath, editionPath, id };
|
||||
}
|
||||
|
||||
describe("specifics-bank CLI — record-usage (Step 8 lock)", () => {
|
||||
test("stamps the edition on the bound specifics and persists it", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath, id } = fixture(dir);
|
||||
|
||||
const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(bankPath, "utf8"));
|
||||
const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id);
|
||||
assert.deepEqual(stamped.usedIn, ["seres/05"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("only specific-bound slots are stamped — abstrakt/ekstern consume no bank material", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath } = fixture(dir);
|
||||
const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.recorded.length, 1);
|
||||
assert.deepEqual(out.unknown, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("re-running after a pivot re-lock is idempotent", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath, id } = fixture(dir);
|
||||
run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]);
|
||||
const again = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]);
|
||||
assert.equal(again.status, 0, again.stderr);
|
||||
|
||||
const out = JSON.parse(again.stdout);
|
||||
assert.deepEqual(out.recorded, []);
|
||||
assert.equal(out.alreadyRecorded.length, 1);
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(bankPath, "utf8"));
|
||||
const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id);
|
||||
assert.deepEqual(stamped.usedIn, ["seres/05"], "a re-lock must not double-stamp");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an edition with no bound specifics is a no-op, exit 0", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath } = fixture(dir);
|
||||
const empty = join(dir, "empty-edition.json");
|
||||
writeFileSync(empty, JSON.stringify({ currentArticle: "06", articles: { "06": {} } }), "utf8");
|
||||
|
||||
const res = run(["record-usage", "--edition", empty, "--edition-id", "seres/06", "--bank", bankPath, "--json"]);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.deepEqual(JSON.parse(res.stdout).recorded, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("bad invocations exit 2", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { bankPath, editionPath } = fixture(dir);
|
||||
assert.equal(run(["record-usage", "--bank", bankPath]).status, 2); // no --edition
|
||||
assert.equal(
|
||||
run(["record-usage", "--edition", editionPath, "--bank", bankPath]).status,
|
||||
2, // no --edition-id: an unattributable use is worse than none
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
157
scripts/specifics-bank/tests/usage.test.ts
Normal file
157
scripts/specifics-bank/tests/usage.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { addSpecific, emptyBank, loadBank, recordUsage, saveBank, specificId } from "../src/bank.js";
|
||||
import { boundSpecificIds } from "../src/binding.js";
|
||||
import type { LivedSpecifics } from "../src/binding.js";
|
||||
import type { Bank } from "../src/types.js";
|
||||
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), "specifics-usage-"));
|
||||
|
||||
const CONTENT = {
|
||||
a: "Vi målte 40 prosent kortere ledetid etter omleggingen.",
|
||||
b: "Kunden avlyste pilotene fordi ingen eide dataene.",
|
||||
};
|
||||
|
||||
function bankWith(...contents: string[]): Bank {
|
||||
let bank = emptyBank();
|
||||
for (const content of contents) {
|
||||
bank = addSpecific(bank, {
|
||||
type: "number",
|
||||
content,
|
||||
topicTags: ["ai"],
|
||||
provenance: { capturedAt: "2026-06-20", source: "test" },
|
||||
}).bank;
|
||||
}
|
||||
return bank;
|
||||
}
|
||||
|
||||
describe("specifics-bank — usage logging (N11: «used in edition NN»)", () => {
|
||||
test("recording usage stamps the edition on the specific", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const id = specificId(CONTENT.a);
|
||||
|
||||
const res = recordUsage(bank, { ids: [id], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(res.recorded, [id]);
|
||||
assert.deepEqual(res.alreadyRecorded, []);
|
||||
assert.deepEqual(res.unknown, []);
|
||||
assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
});
|
||||
|
||||
test("recording the same edition twice is idempotent (a re-lock does not double-count)", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const id = specificId(CONTENT.a);
|
||||
|
||||
const once = recordUsage(bank, { ids: [id], editionId: "seres/05" });
|
||||
const twice = recordUsage(once.bank, { ids: [id], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(twice.recorded, []);
|
||||
assert.deepEqual(twice.alreadyRecorded, [id]);
|
||||
assert.deepEqual(twice.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
});
|
||||
|
||||
test("a second edition appends, preserving order", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const id = specificId(CONTENT.a);
|
||||
|
||||
const first = recordUsage(bank, { ids: [id], editionId: "seres/05" });
|
||||
const second = recordUsage(first.bank, { ids: [id], editionId: "seres/06" });
|
||||
|
||||
assert.deepEqual(second.bank.specifics[0].usedIn, ["seres/05", "seres/06"]);
|
||||
});
|
||||
|
||||
test("an id absent from the bank is reported as unknown, not silently dropped", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.a), "deadbeef1234"], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(res.unknown, ["deadbeef1234"]);
|
||||
assert.deepEqual(res.recorded, [specificId(CONTENT.a)]);
|
||||
});
|
||||
|
||||
test("archived material still records use — the use is historical fact", () => {
|
||||
let bank = emptyBank();
|
||||
bank = addSpecific(bank, {
|
||||
type: "named-case",
|
||||
content: CONTENT.b,
|
||||
topicTags: ["ai"],
|
||||
provenance: { capturedAt: "2026-06-20", source: "test" },
|
||||
status: "archived",
|
||||
}).bank;
|
||||
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.b)], editionId: "seres/05" });
|
||||
assert.deepEqual(res.recorded, [specificId(CONTENT.b)]);
|
||||
assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
});
|
||||
|
||||
test("recording touches only the named specifics", () => {
|
||||
const bank = bankWith(CONTENT.a, CONTENT.b);
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: "seres/05" });
|
||||
|
||||
assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]);
|
||||
assert.equal(res.bank.specifics[1].usedIn, undefined);
|
||||
});
|
||||
|
||||
test("an empty id list is a no-op, not an error", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
const res = recordUsage(bank, { ids: [], editionId: "seres/05" });
|
||||
assert.deepEqual(res.recorded, []);
|
||||
assert.equal(res.bank.specifics[0].usedIn, undefined);
|
||||
});
|
||||
|
||||
test("a blank edition id throws — an unattributable use is worse than none", () => {
|
||||
const bank = bankWith(CONTENT.a);
|
||||
assert.throws(() => recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: " " }), /editionId/);
|
||||
});
|
||||
|
||||
test("usedIn survives a save/load round-trip; an untouched bank stays field-free", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, "specifics-bank.json");
|
||||
const bank = bankWith(CONTENT.a, CONTENT.b);
|
||||
const res = recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: "seres/05" });
|
||||
saveBank(path, res.bank);
|
||||
|
||||
const back = loadBank(path);
|
||||
assert.deepEqual(back.specifics[0].usedIn, ["seres/05"]);
|
||||
// additive-optional: never-used material carries no field at all (schema stays v1)
|
||||
assert.equal("usedIn" in back.specifics[1], false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("specifics-bank — boundSpecificIds (what an edition actually consumed)", () => {
|
||||
const ls = (slots: LivedSpecifics["slots"]): LivedSpecifics => ({ slots, status: "bound" });
|
||||
|
||||
test("extracts the ids of specific-bound slots only", () => {
|
||||
const res = boundSpecificIds(
|
||||
ls([
|
||||
{ slotId: "kp1", kind: "key-point", label: "a", binding: { type: "specific", specificId: "aaa111" } },
|
||||
{ slotId: "kp2", kind: "key-point", label: "b", binding: { type: "abstrakt", rationale: "bevisst" } },
|
||||
{ slotId: "kp3", kind: "section", label: "c", binding: { type: "ekstern", source: "https://x" } },
|
||||
{ slotId: "kp4", kind: "section", label: "d", binding: { type: "unresolved" } },
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(res, ["aaa111"]);
|
||||
});
|
||||
|
||||
test("dedupes an id bound in several slots", () => {
|
||||
const res = boundSpecificIds(
|
||||
ls([
|
||||
{ slotId: "kp1", kind: "key-point", label: "a", binding: { type: "specific", specificId: "aaa111" } },
|
||||
{ slotId: "kp2", kind: "section", label: "b", binding: { type: "specific", specificId: "aaa111" } },
|
||||
{ slotId: "kp3", kind: "section", label: "c", binding: { type: "specific", specificId: "bbb222" } },
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(res, ["aaa111", "bbb222"]);
|
||||
});
|
||||
|
||||
test("an empty slot map yields no ids", () => {
|
||||
assert.deepEqual(boundSpecificIds(ls([])), []);
|
||||
});
|
||||
});
|
||||
|
|
@ -698,7 +698,7 @@ if [ -x "$SB_DIR/node_modules/.bin/tsx" ]; then
|
|||
SB_OUT=$( set +e; (cd "$SB_DIR" && npm test) 2>&1; echo "SB_EXIT:$?" )
|
||||
SB_EXIT=$(echo "$SB_OUT" | grep -oE 'SB_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
SB_TESTS=$(echo "$SB_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||
SPECIFICS_BANK_TESTS_FLOOR=28
|
||||
SPECIFICS_BANK_TESTS_FLOOR=45
|
||||
if [ "$SB_EXIT" = "0" ] && [ -n "$SB_TESTS" ] && [ "$SB_TESTS" -ge "$SPECIFICS_BANK_TESTS_FLOOR" ]; then
|
||||
pass "specifics-bank suite green: $SB_TESTS tests pass (floor $SPECIFICS_BANK_TESTS_FLOOR)"
|
||||
else
|
||||
|
|
@ -710,6 +710,30 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 15b: Series-Distillate Suite (N11 / C-5) ---
|
||||
echo "--- Series Distillate ---"
|
||||
|
||||
# The series distillate (scripts/editions) is wired into /linkedin:newsletter as the Step 2.5
|
||||
# destillat-sjekk and the Step 8 lock write. Same discipline as Section 15: the suite belongs in
|
||||
# CI (store + similarity + skeleton-check stay green, case count never erodes), and an adopter
|
||||
# without deps warn-skips rather than failing. Same set +e / subshell discipline (bash 3.2-safe).
|
||||
ED_DIR="scripts/editions"
|
||||
if [ -x "$ED_DIR/node_modules/.bin/tsx" ]; then
|
||||
ED_OUT=$( set +e; (cd "$ED_DIR" && npm test) 2>&1; echo "ED_EXIT:$?" )
|
||||
ED_EXIT=$(echo "$ED_OUT" | grep -oE 'ED_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
ED_TESTS=$(echo "$ED_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
|
||||
EDITIONS_TESTS_FLOOR=27
|
||||
if [ "$ED_EXIT" = "0" ] && [ -n "$ED_TESTS" ] && [ "$ED_TESTS" -ge "$EDITIONS_TESTS_FLOOR" ]; then
|
||||
pass "editions suite green: $ED_TESTS tests pass (floor $EDITIONS_TESTS_FLOOR)"
|
||||
else
|
||||
fail "editions suite NOT green (exit=${ED_EXIT:-?}, tests=${ED_TESTS:-?}, floor $EDITIONS_TESTS_FLOOR) — run: (cd $ED_DIR && npm test)"
|
||||
fi
|
||||
else
|
||||
warn "editions skipped — deps absent ($ED_DIR/node_modules); run: (cd $ED_DIR && npm install)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 16: Trends-Store Binding (research-engine slice 2b) ---
|
||||
echo "--- Trends-Store Binding ---"
|
||||
|
||||
|
|
@ -1804,6 +1828,106 @@ fi
|
|||
|
||||
echo ""
|
||||
|
||||
# --- Section 16r: Series-Level Protection - distillate + usage log (N11 / C-5) ---
|
||||
echo "--- Series-Level Protection (N11) ---"
|
||||
|
||||
# N11 makes "the reader has heard this before" visible BEFORE the skeleton is approved.
|
||||
# Every other gate sees ONE edition; at series cadence the fastest-growing defect is a
|
||||
# REPEATED edition, and the specifics-bank dedupe actively encourages re-surfacing material.
|
||||
# Two stores, two grains, both UNCONDITIONAL greps (they raise the deps-absent floor):
|
||||
# (C-5 series grain) scripts/editions - what THIS SERIES already published (anecdote/argument/hook)
|
||||
# (C-5 material grain) specifics-bank usedIn - which editions consumed a given lived specific
|
||||
# (C-5 Step 2.5) the destillat-sjekk feeds the annotation gate BEFORE prose - a compound predicate
|
||||
# (distil-check call + the series-scoped store + the advisory REUSE verdict), so a
|
||||
# non-vacuity self-test guards it (mirrors Sections 16p/16q).
|
||||
# (C-5 Step 8) lock writes both stores - published, never merely considered.
|
||||
NL_N11="commands/newsletter.md"
|
||||
DIST_SRC="scripts/editions/src/distillate.ts"
|
||||
|
||||
distillate_gated() { # $1 = text; wired iff it CALLS the check, names the series-scoped store AND carries the advisory verdict
|
||||
echo "$1" | grep -qF "distil-check" \
|
||||
&& echo "$1" | grep -qF "series-distillate.json" \
|
||||
&& echo "$1" | grep -qF "REUSE"
|
||||
}
|
||||
|
||||
DG_SELFTEST_OK=1
|
||||
if ! distillate_gated "run distil-check --distillate <abs>/linkedin/series-distillate.json; on REUSE fold the finding into the annotation page"; then
|
||||
DG_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired destillat-sjekk probe was not detected"
|
||||
fi
|
||||
while IFS= read -r probe; do
|
||||
[ -z "$probe" ] && continue
|
||||
if distillate_gated "$probe"; then
|
||||
DG_SELFTEST_OK=0; echo " false-positive FAIL: under-wired destillat probe accepted -> $probe"
|
||||
fi
|
||||
done <<'NEGATIVE16R'
|
||||
run distil-check against the store and act on REUSE without naming the file
|
||||
the series-distillate.json store exists and REUSE is possible, but nothing calls the check
|
||||
call distil-check on series-distillate.json but never say what a hit means
|
||||
NEGATIVE16R
|
||||
if [ "$DG_SELFTEST_OK" -eq 1 ]; then
|
||||
pass "destillat-gate self-test: predicate needs distil-check + series-distillate.json + REUSE verdict (1 accepted, 3 under-wired rejected)"
|
||||
else
|
||||
fail "destillat-gate self-test failed - the N11 wiring lint is vacuous or over-eager"
|
||||
fi
|
||||
|
||||
# (C-5 Step 2.5) the destillat-sjekk runs in the skeleton gate, BEFORE prose
|
||||
STEP25_BLOCK=$(awk '/^## Step 2.5:/{f=1} /^## Step 3a:/{f=0} f' "$NL_N11")
|
||||
if distillate_gated "$STEP25_BLOCK"; then
|
||||
pass "newsletter Step 2.5 runs the destillat-sjekk against the series store with an advisory REUSE verdict (C-5 Step 2.5)"
|
||||
else
|
||||
fail "newsletter Step 2.5 does not run the destillat-sjekk - series-level re-use stays invisible before prose (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 Step 8 distillate) lock folds the spent units into the series distillate
|
||||
STEP8_BLOCK=$(awk '/^## Step 8:/{f=1} /^## Step 9:/{f=0} f' "$NL_N11")
|
||||
if echo "$STEP8_BLOCK" | grep -qF "distil-append"; then
|
||||
pass "newsletter Step 8 lock writes the series distillate (distil-append) (C-5 Step 8)"
|
||||
else
|
||||
fail "newsletter Step 8 never calls distil-append - nothing feeds the next edition's Step 2.5 check (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 Step 8 usage) lock stamps the consumed lived material
|
||||
if echo "$STEP8_BLOCK" | grep -qF "record-usage"; then
|
||||
pass "newsletter Step 8 lock stamps consumed specifics (record-usage) (C-5 Step 8)"
|
||||
else
|
||||
fail "newsletter Step 8 never calls record-usage - bank re-use across editions stays invisible (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 material grain) the bank record carries the usage log
|
||||
if grep -qF "usedIn" scripts/specifics-bank/src/types.ts; then
|
||||
pass "specifics-bank Specific declares usedIn - the cross-edition usage log exists (C-5 material grain)"
|
||||
else
|
||||
fail "specifics-bank/src/types.ts has no usedIn field - «used in edition NN» is unrecordable (C-5)"
|
||||
fi
|
||||
|
||||
if grep -qF "export function recordUsage" scripts/specifics-bank/src/bank.ts; then
|
||||
pass "specifics-bank exports recordUsage (C-5 material grain)"
|
||||
else
|
||||
fail "specifics-bank/src/bank.ts does not export recordUsage (C-5)"
|
||||
fi
|
||||
|
||||
if grep -qF "export function boundSpecificIds" scripts/specifics-bank/src/binding.ts; then
|
||||
pass "specifics-bank exports boundSpecificIds - only specific-bound slots count as consumption (C-5)"
|
||||
else
|
||||
fail "specifics-bank/src/binding.ts does not export boundSpecificIds (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 series grain) the distillate module exists with its deterministic check
|
||||
if grep -qF "export function checkSkeleton" "$DIST_SRC"; then
|
||||
pass "scripts/editions exports checkSkeleton - the deterministic series check exists (C-5 series grain)"
|
||||
else
|
||||
fail "$DIST_SRC does not export checkSkeleton (C-5)"
|
||||
fi
|
||||
|
||||
# (C-5 placement) the distillate lives in the SERIES root beside edition-state, not the data dir
|
||||
if grep -qF "series-distillate.json" "$DIST_SRC" && grep -qF '"linkedin"' "$DIST_SRC"; then
|
||||
pass "series distillate resolves under <serie>/linkedin/ beside edition-state (C-5 placement)"
|
||||
else
|
||||
fail "$DIST_SRC does not resolve the distillate under the series root - series-scoped state placed wrong (C-5)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
|
||||
# The lint self-modifies its own checks, so a green run could mask a silently dropped
|
||||
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
|
||||
|
|
@ -1842,12 +1966,16 @@ echo ""
|
|||
# (targetLevel-consumption self-test + no-hardcoded-line-manager absence grep + references target-level-span
|
||||
# grep + personas opposite-end obligatorisk-sekundær grep + personas explicit-ceiling-justification grep +
|
||||
# personas technical-end seed grep + edition-state targetLevel-slot grep + newsletter articles.NN.targetLevel
|
||||
# persist grep + persona-reviewer targetLevel-consumption compound grep) = 146.
|
||||
# persist grep + persona-reviewer targetLevel-consumption compound grep) = 146; +9 for N11's nine
|
||||
# UNCONDITIONAL Section-16r checks (destillat-gate self-test + Step-2.5 destillat_gated compound grep +
|
||||
# Step-8 distil-append grep + Step-8 record-usage grep + types.ts usedIn grep + bank.ts recordUsage grep +
|
||||
# binding.ts boundSpecificIds grep + distillate.ts checkSkeleton grep + distillate.ts series-root
|
||||
# placement grep) = 155.
|
||||
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
|
||||
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
|
||||
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
|
||||
# clone). Runs last so TOTAL_CHECKS sees every prior check.
|
||||
ASSERT_BASELINE_FLOOR=146
|
||||
ASSERT_BASELINE_FLOOR=155
|
||||
TOTAL_CHECKS=$((PASS + FAIL))
|
||||
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
|
||||
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue