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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue