@supersuit/hyperspec 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -0
- package/README.md +71 -8
- package/SPEC.md +2 -2
- package/WRITING.md +680 -21
- package/bin/hyperspec.mjs +188 -4
- package/examples/writing/course/claims.jsonl +0 -0
- package/examples/writing/course/goldens/lesson.md +1 -0
- package/examples/writing/course/materials/brief.md +9 -0
- package/examples/writing/course/materials/brief.md.segments.jsonl +6 -0
- package/examples/writing/course/outline.md +11 -0
- package/examples/writing/course/part-1.md +47 -0
- package/examples/writing/course/part-2.md +40 -0
- package/examples/writing/course/runs.jsonl +0 -0
- package/examples/writing/course.hyperspec.md +205 -0
- package/examples/writing/essay/judge/doctor.packet.json +108 -0
- package/examples/writing/essay/judge/lineup.packet.json +64 -0
- package/examples/writing/essay/judge/persona.packet.json +73 -0
- package/examples/writing/essay/judge/reader.packet.json +93 -0
- package/examples/writing/essay/learn/first-draft.md +84 -0
- package/examples/writing/essay/learn/learn.packet.json +106 -0
- package/examples/writing/essay/sample-verdicts/doctor.verdict.json +43 -0
- package/examples/writing/essay/sample-verdicts/learn.verdict.json +30 -0
- package/examples/writing/essay/sample-verdicts/lineup.verdict.json +6 -0
- package/examples/writing/essay/sample-verdicts/persona.verdict.json +4 -0
- package/examples/writing/essay/sample-verdicts/reader.verdict.json +7 -0
- package/examples/writing/essay.hyperspec.md +6 -1
- package/examples/writing/story/judge/attribution.packet.json +194 -0
- package/examples/writing/story/judge/doctor.packet.json +108 -0
- package/examples/writing/story/judge/knowledge.packet.json +77 -0
- package/examples/writing/story/judge/persona.packet.json +73 -0
- package/examples/writing/story/judge/reader.packet.json +94 -0
- package/examples/writing/story/sample-verdicts/attribution.verdict.json +81 -0
- package/examples/writing/story/sample-verdicts/doctor.verdict.json +43 -0
- package/examples/writing/story/sample-verdicts/knowledge.verdict.json +4 -0
- package/examples/writing/story/sample-verdicts/persona.verdict.json +20 -0
- package/examples/writing/story/sample-verdicts/reader.verdict.json +16 -0
- package/examples/writing/story.hyperspec.md +7 -3
- package/package.json +1 -1
- package/src/check.mjs +96 -132
- package/src/draft.mjs +26 -0
- package/src/judge.mjs +386 -0
- package/src/judges/attribution.mjs +360 -0
- package/src/judges/doctor.mjs +126 -0
- package/src/judges/index.mjs +31 -0
- package/src/judges/knowledge.mjs +111 -0
- package/src/judges/lineup.mjs +272 -0
- package/src/judges/persona.mjs +137 -0
- package/src/judges/reader.mjs +111 -0
- package/src/learn.mjs +422 -0
- package/src/ledger.mjs +108 -0
- package/src/sentences.mjs +81 -0
- package/src/sequence-draft.mjs +75 -0
- package/src/stations/claims.mjs +44 -39
- package/src/stations/index.mjs +3 -1
- package/src/stations/links.mjs +11 -3
- package/src/stations/quotes.mjs +6 -4
- package/src/stations/sequence.mjs +275 -0
- package/src/writing-fields.mjs +34 -0
- package/src/writing.mjs +1 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// The lineup: a blind test of voice. Up to three goldens from the spec's DNA scope (passages a
|
|
2
|
+
// person approved as this writer, in this scope) go in a lineup with one passage of the draft,
|
|
3
|
+
// shuffled and labelled; a judge who can pick the draft's passage out has found that the draft does
|
|
4
|
+
// not yet sound like the writer. The station passes when the judge picks a golden.
|
|
5
|
+
//
|
|
6
|
+
// Every candidate is built the same way, so none can be told apart by its formatting:
|
|
7
|
+
// ONE prose paragraph (proseParagraphs below), reflowed to a single line. The target length is the
|
|
8
|
+
// median, in characters, of every prose paragraph of every golden in the scope. Each of the scope's
|
|
9
|
+
// first three goldens with a prose paragraph (by file name, the order src/dna.mjs's reader returns
|
|
10
|
+
// them in) contributes its paragraph closest to that target; the draft contributes its paragraph
|
|
11
|
+
// closest to the same target, skipping any that is already a golden paragraph word for word
|
|
12
|
+
//, since a lineup of two identical passages tests nothing. Ties go to the earliest.
|
|
13
|
+
//
|
|
14
|
+
// The shuffle is seeded from the draft's full text (lineupSeed below), so the same draft always gets
|
|
15
|
+
// the same labels and a revised draft gets a fresh draw. The seed is never a value the packet
|
|
16
|
+
// carries: the packet holds the draft's hash and one paragraph of it, and a seed taken from the
|
|
17
|
+
// hash would let anyone holding the packet rerun the shuffle and read off the answer. The draft's
|
|
18
|
+
// label is the hidden answer: it goes only in the station's key, which prepare writes to
|
|
19
|
+
// lineup.key.json for a person to read and record rebuilds rather than reading.
|
|
20
|
+
|
|
21
|
+
import { resolve } from "node:path";
|
|
22
|
+
import { sha256 } from "../hash.mjs";
|
|
23
|
+
import { str } from "../placeholder.mjs";
|
|
24
|
+
import { readScope } from "../dna.mjs";
|
|
25
|
+
import { splitLines } from "../draft.mjs";
|
|
26
|
+
|
|
27
|
+
export const name = "lineup";
|
|
28
|
+
|
|
29
|
+
export const MAX_GOLDENS = 3;
|
|
30
|
+
export const LABELS = Object.freeze(["A", "B", "C", "D"]);
|
|
31
|
+
|
|
32
|
+
export const LINEUP_INSTRUCTIONS = [
|
|
33
|
+
"Each passage in inputs.candidates carries a capital-letter label.",
|
|
34
|
+
"All but one were written by the writer of inputs.scope, for this form, audience and purpose, and approved by a person; exactly one comes from a new draft.",
|
|
35
|
+
"Pick the label of the passage you believe comes from the new draft, judging by voice alone: rhythm, diction, sentence shape, what this writer would and would not say.",
|
|
36
|
+
"Give your confidence from 0 (a guess) to 1 (certain), and in reason say what in the candidates decided it.",
|
|
37
|
+
"Judge from the packet's inputs alone: do not open the spec, the draft or any other file the packet names.",
|
|
38
|
+
"Answer only in the verdict shape given in verdict_schema.",
|
|
39
|
+
].join(" ");
|
|
40
|
+
|
|
41
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
42
|
+
const chars = (s) => [...s].length;
|
|
43
|
+
|
|
44
|
+
// ---- the draft's prose paragraphs ----------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
47
|
+
const HEADING = /^ {0,3}#{1,6}(?:[ \t]|$)/;
|
|
48
|
+
// A line that makes its block something other than a prose paragraph: a list item, a blockquote, a
|
|
49
|
+
// table row, a thematic break or setext underline (front matter's --- too), or an HTML line.
|
|
50
|
+
const NOT_PROSE = [
|
|
51
|
+
/^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/,
|
|
52
|
+
/^ {0,3}>/,
|
|
53
|
+
/^\s*\|/,
|
|
54
|
+
/^ {0,3}(?:(?:[-*_][ \t]*){3,}|=+[ \t]*)$/,
|
|
55
|
+
/^ {0,3}<[A-Za-z/!?]/,
|
|
56
|
+
];
|
|
57
|
+
const INDENTED_CODE = /^(?: {4}|\t)/;
|
|
58
|
+
|
|
59
|
+
// proseParagraphs(text): [{ line, text }] for every prose paragraph of a markdown draft, in order.
|
|
60
|
+
// A block is a run of non-blank lines; fenced code (blank lines inside it included) and ATX headings
|
|
61
|
+
// end a block and are never part of one. A block is prose only when every one of its lines is plain
|
|
62
|
+
// text: a block holding a list item, a quotation, a table row, a break or HTML is left out whole
|
|
63
|
+
// (a paragraph that runs into a list is not a passage a golden could stand beside), and so is a
|
|
64
|
+
// block opening with indented code. text is the block's lines, trailing whitespace trimmed, joined
|
|
65
|
+
// with "\n"; line is the 1-based draft line it starts on.
|
|
66
|
+
export function proseParagraphs(text) {
|
|
67
|
+
const lines = splitLines(String(text));
|
|
68
|
+
const out = [];
|
|
69
|
+
let block = [];
|
|
70
|
+
let fence = null;
|
|
71
|
+
const flush = () => {
|
|
72
|
+
if (block.length && !INDENTED_CODE.test(block[0].text) && block.every((l) => !NOT_PROSE.some((re) => re.test(l.text)))) {
|
|
73
|
+
out.push({ line: block[0].line, text: block.map((l) => l.text.trimEnd()).join("\n") });
|
|
74
|
+
}
|
|
75
|
+
block = [];
|
|
76
|
+
};
|
|
77
|
+
lines.forEach((raw, i) => {
|
|
78
|
+
const m = raw.match(FENCE);
|
|
79
|
+
if (fence) {
|
|
80
|
+
if (m && m[1][0] === fence[0] && m[1].length >= fence.length && !raw.slice(m[0].length).trim()) fence = null;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (m) { flush(); fence = m[1]; return; }
|
|
84
|
+
if (!raw.trim() || HEADING.test(raw)) { flush(); return; }
|
|
85
|
+
block.push({ line: i + 1, text: raw });
|
|
86
|
+
});
|
|
87
|
+
flush();
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function median(values) {
|
|
92
|
+
const s = [...values].sort((a, b) => a - b);
|
|
93
|
+
const mid = Math.floor(s.length / 2);
|
|
94
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// reflow(text): the paragraph on one line, every run of whitespace (line breaks included) collapsed
|
|
98
|
+
// to one space. Lineup carries no evidence, so nothing in a candidate has to stay verbatim.
|
|
99
|
+
export const reflow = (text) => String(text).replace(/\s+/g, " ").trim();
|
|
100
|
+
|
|
101
|
+
// pickPassage(paragraphs, lengths, { exclude }): the paragraph whose length in characters is
|
|
102
|
+
// closest to the median of `lengths`; on a tie, the earliest. A paragraph whose text is in
|
|
103
|
+
// `exclude` (a Set) is never picked. null when no paragraph is left.
|
|
104
|
+
export function pickPassage(paragraphs, lengths, { exclude } = {}) {
|
|
105
|
+
const target = median(lengths);
|
|
106
|
+
let best = null;
|
|
107
|
+
let bestDistance = Infinity;
|
|
108
|
+
for (const p of paragraphs) {
|
|
109
|
+
if (exclude?.has(p.text)) continue;
|
|
110
|
+
const d = Math.abs(chars(p.text) - target);
|
|
111
|
+
if (d < bestDistance) { best = p; bestDistance = d; }
|
|
112
|
+
}
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- the shuffle ---------------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
// mulberry32: a small, well-mixed 32-bit PRNG, so the shuffle needs no dependency and gives the
|
|
119
|
+
// same sequence on every Node version.
|
|
120
|
+
function mulberry32(seed) {
|
|
121
|
+
let a = seed >>> 0;
|
|
122
|
+
return () => {
|
|
123
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
124
|
+
let t = a;
|
|
125
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
126
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
127
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// seededShuffle(items, sha256Hex): a new array, items in a Fisher-Yates order drawn from a PRNG
|
|
132
|
+
// seeded with the first 32 bits of the hash. The same items and hash always give the same order.
|
|
133
|
+
export function seededShuffle(items, sha256Hex) {
|
|
134
|
+
const random = mulberry32(parseInt(String(sha256Hex).slice(0, 8), 16) || 0);
|
|
135
|
+
const out = [...items];
|
|
136
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
137
|
+
const j = Math.floor(random() * (i + 1));
|
|
138
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// lineupSeed(draftText): the hash the shuffle is seeded with. It is taken from the draft's whole
|
|
144
|
+
// text under a fixed prefix, never from draft_sha256 or anything else the packet carries, since the
|
|
145
|
+
// packet does not carry the draft's text: a judge holding only the packet cannot recompute the
|
|
146
|
+
// order, while the same draft always gets the same labels.
|
|
147
|
+
export const LINEUP_SEED_PREFIX = "hyperspec lineup seed\n";
|
|
148
|
+
export const lineupSeed = (draftText) => sha256(LINEUP_SEED_PREFIX + String(draftText));
|
|
149
|
+
|
|
150
|
+
// ---- the station ---------------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
// Every prose paragraph of a text, reflowed to one line: [{ line, text }].
|
|
153
|
+
const reflowedParagraphs = (text) => proseParagraphs(text).map((p) => ({ line: p.line, text: reflow(p.text) }));
|
|
154
|
+
|
|
155
|
+
// What the lineup would hold for this spec and draft, or why it cannot be built: { skip } or
|
|
156
|
+
// { scope, goldens: [{ source, text }], passage: { line, text } }. The one plan both skipReason and
|
|
157
|
+
// packet read, so a station that applies always has a packet to build. Without a draft (never the
|
|
158
|
+
// case from prepare or record) only the scope's half is checked.
|
|
159
|
+
function lineupPlan(spec, draft) {
|
|
160
|
+
const dna = spec.data?.writing?.dna;
|
|
161
|
+
if (!isObject(dna)) return { skip: "writing.dna is not written (deferred)" };
|
|
162
|
+
const scopeDir = str(dna.scope_dir);
|
|
163
|
+
if (!scopeDir) return { skip: "writing.dna.scope_dir is not set" };
|
|
164
|
+
if (!str(dna.check?.rubric)) return { skip: "writing.dna.check has no rubric" };
|
|
165
|
+
|
|
166
|
+
const disk = readScope(resolve(spec.dir || ".", scopeDir), { displayDir: scopeDir });
|
|
167
|
+
if (disk.findings.some((x) => x.id === "writing-dna-goldens-missing" || x.id === "writing-dna-goldens-outside")) {
|
|
168
|
+
return { skip: `writing.dna.scope_dir "${scopeDir}": its goldens cannot be read (run \`hyperspec lint\` for details)`, fromSources: true };
|
|
169
|
+
}
|
|
170
|
+
const read = disk.goldens.filter((g) => g.text).map((g) => ({ source: g.path, paragraphs: reflowedParagraphs(g.text) }));
|
|
171
|
+
if (!read.length) return { skip: `writing.dna.scope_dir "${scopeDir}" has no goldens`, fromSources: true };
|
|
172
|
+
const withProse = read.filter((g) => g.paragraphs.length);
|
|
173
|
+
if (!withProse.length) return { skip: `writing.dna.scope_dir "${scopeDir}" has no golden with a prose paragraph`, fromSources: true };
|
|
174
|
+
|
|
175
|
+
const all = withProse.flatMap((g) => g.paragraphs);
|
|
176
|
+
const lengths = all.map((p) => chars(p.text));
|
|
177
|
+
const goldens = withProse.slice(0, MAX_GOLDENS).map((g) => ({ source: g.source, text: pickPassage(g.paragraphs, lengths).text }));
|
|
178
|
+
if (!draft) return { scope: disk.scope, goldens, passage: null };
|
|
179
|
+
|
|
180
|
+
const drafted = reflowedParagraphs(draft.text);
|
|
181
|
+
if (!drafted.length) return { skip: "the draft has no prose paragraph to put in the lineup" };
|
|
182
|
+
const passage = pickPassage(drafted, lengths, { exclude: new Set(all.map((p) => p.text)) });
|
|
183
|
+
if (!passage) return { skip: "every prose paragraph of the draft is already a golden in the scope, word for word", fromSources: true };
|
|
184
|
+
// A draft that is nothing but this paragraph would give the answer away: the packet carries the
|
|
185
|
+
// draft's hash, and the candidate's text (with or without a line break) is the whole draft.
|
|
186
|
+
if (reflow(draft.text) === passage.text) return { skip: "the draft is one paragraph, the passage the lineup would show, so the packet's draft_sha256 could identify it" };
|
|
187
|
+
return { scope: disk.scope, goldens, passage };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// null when the lineup applies, otherwise why not: it needs a written dna block whose scope_dir is
|
|
191
|
+
// set and whose check names a rubric, a golden with a prose paragraph in that scope, and a prose
|
|
192
|
+
// paragraph in the draft that is not already a golden and is not the whole draft.
|
|
193
|
+
export function skipReason(spec, draft) {
|
|
194
|
+
return lineupPlan(spec, draft).skip ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The skip reason when lineup skips because of its goldens (unreadable, none, none with prose, or
|
|
198
|
+
// every draft paragraph already one of them), else null: record calls such a packet stale rather
|
|
199
|
+
// than altered.
|
|
200
|
+
export function sourceSkip(spec, draft) {
|
|
201
|
+
const p = lineupPlan(spec, draft);
|
|
202
|
+
return p.fromSources ? p.skip : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Where the lineup's inputs come from beyond the spec and the draft: record names them when the
|
|
206
|
+
// packet's inputs no longer match while neither hash changed.
|
|
207
|
+
export function inputSources(spec) {
|
|
208
|
+
const dir = str(spec.data?.writing?.dna?.scope_dir);
|
|
209
|
+
return dir ? `the DNA scope (${dir}: scope.md and goldens)` : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The packet's rubric (dna.check.rubric, verbatim), inputs (the scope and the labelled candidates,
|
|
213
|
+
// text only) and verdict schema, and the key: which label is the draft's, and where every
|
|
214
|
+
// candidate came from.
|
|
215
|
+
export function packet(spec, draft) {
|
|
216
|
+
const { scope, goldens, passage } = lineupPlan(spec, draft);
|
|
217
|
+
const pool = [...goldens, { source: "draft", text: passage.text }];
|
|
218
|
+
const order = seededShuffle(pool, lineupSeed(draft.text));
|
|
219
|
+
const labels = LABELS.slice(0, order.length);
|
|
220
|
+
return {
|
|
221
|
+
rubric: spec.data.writing.dna.check.rubric,
|
|
222
|
+
inputs: {
|
|
223
|
+
scope: { writer: str(scope?.writer), form: str(scope?.form), audience: str(scope?.audience), purpose: str(scope?.purpose) },
|
|
224
|
+
candidates: order.map((c, i) => ({ label: labels[i], text: c.text })),
|
|
225
|
+
},
|
|
226
|
+
verdict_schema: {
|
|
227
|
+
type: "object",
|
|
228
|
+
required: ["pick", "confidence", "reason"],
|
|
229
|
+
properties: {
|
|
230
|
+
pick: { enum: labels, description: "the label of the passage you believe comes from the new draft" },
|
|
231
|
+
confidence: { type: "number", minimum: 0, maximum: 1 },
|
|
232
|
+
reason: { type: "string", description: "what in the candidates decided the pick" },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
key: {
|
|
236
|
+
station: name,
|
|
237
|
+
draft_label: labels[order.findIndex((c) => c.source === "draft")],
|
|
238
|
+
draft_line: passage.line,
|
|
239
|
+
candidates: order.map((c, i) => ({ label: labels[i], source: c.source })),
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Every problem with the verdict, as findings. The reason describes the candidates rather than
|
|
245
|
+
// quoting the draft, so it carries no evidence.
|
|
246
|
+
export function validate(verdict, pkt, t) {
|
|
247
|
+
if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { pick, confidence, reason }.")];
|
|
248
|
+
const out = [];
|
|
249
|
+
const labels = pkt.inputs.candidates.map((c) => c.label);
|
|
250
|
+
if (typeof verdict.pick !== "string") out.push(t.shape("pick is missing or not a string", `Set pick to one candidate's label: ${labels.join(", ")}.`));
|
|
251
|
+
else if (!labels.includes(verdict.pick)) out.push(t.finding("judge-lineup-pick-unknown", `pick "${verdict.pick}" is not a label in the lineup`, `Pick one of the labels: ${labels.join(", ")}.`));
|
|
252
|
+
if (typeof verdict.confidence !== "number" || !Number.isFinite(verdict.confidence) || verdict.confidence < 0 || verdict.confidence > 1) {
|
|
253
|
+
out.push(t.shape("confidence is missing or not a number from 0 to 1", "Set confidence to a number from 0 (a guess) to 1 (certain)."));
|
|
254
|
+
}
|
|
255
|
+
if (typeof verdict.reason !== "string" || !verdict.reason.trim()) out.push(t.shape("reason is missing or empty", "Say what in the candidates decided the pick."));
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Passes when the pick is a golden: the judge could not tell the draft from the writer.
|
|
260
|
+
export function derive(verdict, pkt, t, key) {
|
|
261
|
+
if (verdict.pick !== key.draft_label) return { status: "pass", findings: [] };
|
|
262
|
+
const n = pkt.inputs.candidates.length;
|
|
263
|
+
return {
|
|
264
|
+
status: "fail",
|
|
265
|
+
findings: [t.finding(
|
|
266
|
+
"judge-lineup-picked",
|
|
267
|
+
`the judge picked the draft's passage (${verdict.pick}) out of ${n} candidates with confidence ${verdict.confidence}: ${verdict.reason.trim()}`,
|
|
268
|
+
"Revise this passage toward the goldens' voice, where the reason points; if the goldens do not cover this kind of passage, add one that does. Then prepare and judge again.",
|
|
269
|
+
key.draft_line,
|
|
270
|
+
)],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The persona: a judge reads the draft as the persona block describes its speaker (who they are,
|
|
2
|
+
// their stance, what they may assert and what they will not say) against the claims ledger, the
|
|
3
|
+
// closed list of facts the draft may state, and reports every place the voice breaks. hyperspec
|
|
4
|
+
// writes the packet, checks every quoted span against the draft (src/judge.mjs's evidence rule) and
|
|
5
|
+
// derives the status: pass when there is no break.
|
|
6
|
+
//
|
|
7
|
+
// The claims are the texts of the ledger the claims station reads (writing.sources.ledger, through
|
|
8
|
+
// its readClaimsLedger), so both stations see the same claims. That file is neither the spec nor the
|
|
9
|
+
// draft, so the station names it in inputSources: a ledger changed after prepare makes the packet
|
|
10
|
+
// stale, not altered. With no ledger declared (writing.sources deferred) the claims are null, the
|
|
11
|
+
// instructions say facts cannot be checked against sources, and a break of kind unsourced_fact is
|
|
12
|
+
// an invalid verdict: an empty list would read as "no fact is sourced". A ledger
|
|
13
|
+
// declared but unreadable skips the station, for the same reason.
|
|
14
|
+
|
|
15
|
+
import { str } from "../placeholder.mjs";
|
|
16
|
+
import { readClaimsLedger } from "../stations/claims.mjs";
|
|
17
|
+
|
|
18
|
+
export const name = "persona";
|
|
19
|
+
|
|
20
|
+
// The four ways a persona breaks, a closed set, in the order the verdict schema lists them.
|
|
21
|
+
export const PERSONA_KINDS = Object.freeze(["stance", "assertion", "will_not_say", "unsourced_fact"]);
|
|
22
|
+
|
|
23
|
+
export const PERSONA_INSTRUCTIONS = [
|
|
24
|
+
"Read inputs.draft as the speaker inputs.persona describes.",
|
|
25
|
+
"Report every break as an entry in breaks: the passage as evidence, copied verbatim from inputs.draft, at least three whole words; its kind; and why.",
|
|
26
|
+
"The kind is one of: stance (the voice leaves inputs.persona.stance), assertion (it asserts something outside inputs.persona.may_assert), will_not_say (it says something inputs.persona.will_not_say rules out), unsourced_fact (it states a fact that none of inputs.claims holds).",
|
|
27
|
+
"When inputs.claims is null, no claims ledger is declared and facts cannot be checked against sources: do not report unsourced_fact.",
|
|
28
|
+
"If the voice holds throughout, breaks is an empty list.",
|
|
29
|
+
"Answer only in the verdict shape given in verdict_schema.",
|
|
30
|
+
].join(" ");
|
|
31
|
+
|
|
32
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
33
|
+
const texts = (v) => (Array.isArray(v) ? v.map(str).filter(Boolean) : []);
|
|
34
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
|
|
35
|
+
|
|
36
|
+
// null when the persona applies: a written persona block whose check names a rubric, and a claims
|
|
37
|
+
// ledger that is either not declared or readable.
|
|
38
|
+
export function skipReason(spec) {
|
|
39
|
+
const persona = spec.data?.writing?.persona;
|
|
40
|
+
if (!isObject(persona)) return "writing.persona is not written (deferred)";
|
|
41
|
+
if (!str(persona.check?.rubric)) return "writing.persona.check has no rubric";
|
|
42
|
+
const ledger = readClaimsLedger(spec);
|
|
43
|
+
if (ledger.missing) return ledgerSkip(ledger.path);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ledgerSkip = (path) => `writing.sources.ledger "${path}" cannot be read (run \`hyperspec check\` for details)`;
|
|
48
|
+
|
|
49
|
+
// The skip reason when the station skips because of its source file (the claims ledger cannot be
|
|
50
|
+
// read), else null: record calls such a packet stale rather than altered.
|
|
51
|
+
export function sourceSkip(spec) {
|
|
52
|
+
const persona = spec.data?.writing?.persona;
|
|
53
|
+
if (!isObject(persona) || !str(persona.check?.rubric)) return null;
|
|
54
|
+
const ledger = readClaimsLedger(spec);
|
|
55
|
+
return ledger.missing ? ledgerSkip(ledger.path) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The file besides the spec and the draft this station's inputs come from, or null when there is none.
|
|
59
|
+
export function inputSources(spec) {
|
|
60
|
+
const path = readClaimsLedger(spec).path;
|
|
61
|
+
return path ? `the claims ledger (${path})` : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The packet's rubric (persona.check.rubric, verbatim), inputs and verdict schema. No answer key.
|
|
65
|
+
export function packet(spec, draft) {
|
|
66
|
+
const p = spec.data.writing.persona;
|
|
67
|
+
const ledger = readClaimsLedger(spec);
|
|
68
|
+
return {
|
|
69
|
+
rubric: p.check.rubric,
|
|
70
|
+
inputs: {
|
|
71
|
+
persona: {
|
|
72
|
+
identity: str(p.identity),
|
|
73
|
+
stance: str(p.stance),
|
|
74
|
+
may_assert: texts(p.may_assert),
|
|
75
|
+
will_not_say: texts(p.will_not_say),
|
|
76
|
+
},
|
|
77
|
+
claims: ledger.path ? ledger.lines.filter((l) => !l.problem).map((l) => l.text) : null,
|
|
78
|
+
draft: draft.text,
|
|
79
|
+
},
|
|
80
|
+
verdict_schema: {
|
|
81
|
+
type: "object",
|
|
82
|
+
required: ["breaks"],
|
|
83
|
+
properties: {
|
|
84
|
+
breaks: {
|
|
85
|
+
type: "array",
|
|
86
|
+
description: "every place the persona breaks; empty when it holds",
|
|
87
|
+
items: {
|
|
88
|
+
type: "object",
|
|
89
|
+
required: ["evidence", "kind", "why"],
|
|
90
|
+
properties: {
|
|
91
|
+
evidence: { type: "string", description: "the passage, copied verbatim from the draft, at least three words" },
|
|
92
|
+
kind: { enum: ledger.path ? [...PERSONA_KINDS] : PERSONA_KINDS.filter((k) => k !== "unsourced_fact") },
|
|
93
|
+
why: { type: "string" },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
key: null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Every problem with the verdict, as findings; every evidence span must be in the draft.
|
|
104
|
+
export function validate(verdict, pkt, t) {
|
|
105
|
+
if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { breaks }.")];
|
|
106
|
+
if (!Array.isArray(verdict.breaks)) return [t.shape("breaks is missing or not a list", "Give breaks: one { evidence, kind, why } per break, or [] when the persona holds.")];
|
|
107
|
+
const out = [];
|
|
108
|
+
verdict.breaks.forEach((b, i) => {
|
|
109
|
+
const at = `breaks[${i}]`;
|
|
110
|
+
if (!isObject(b)) { out.push(t.shape(`${at} is not an object`, "Write each break as { evidence, kind, why }.")); return; }
|
|
111
|
+
if (!PERSONA_KINDS.includes(b.kind)) {
|
|
112
|
+
const shown = typeof b.kind === "string" ? `"${b.kind}"` : b.kind === undefined ? "(missing)" : JSON.stringify(b.kind);
|
|
113
|
+
out.push(t.finding("judge-persona-kind-unknown", `${at}.kind ${shown} is not one of ${PERSONA_KINDS.join(", ")}`, `Set kind to one of ${PERSONA_KINDS.join(", ")}.`));
|
|
114
|
+
} else if (b.kind === "unsourced_fact" && pkt.inputs.claims === null) {
|
|
115
|
+
out.push(t.finding("judge-persona-no-ledger", `${at}.kind is unsourced_fact, but the spec declares no claims ledger, so no fact can be checked against sources`, "Drop this break, or report it as another kind if the voice breaks there too."));
|
|
116
|
+
}
|
|
117
|
+
if (!nonEmpty(b.why)) out.push(t.shape(`${at}.why is missing or empty`, "Say why, in a sentence."));
|
|
118
|
+
out.push(...t.evidence(b.evidence, `${at}.evidence`));
|
|
119
|
+
});
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Passes when there is no break; each break is a failure at its passage's line.
|
|
124
|
+
export function derive(verdict, pkt, t) {
|
|
125
|
+
const stance = pkt.inputs.persona.stance;
|
|
126
|
+
const said = {
|
|
127
|
+
stance: [`the persona leaves its stance (${stance})`, `Revise the passage so it speaks as a ${stance} throughout.`],
|
|
128
|
+
assertion: ["the persona asserts what it may not", "Cut the assertion, or revise it into something persona.may_assert allows."],
|
|
129
|
+
will_not_say: ["the persona says what it will not say", "Cut what persona.will_not_say rules out."],
|
|
130
|
+
unsourced_fact: ["the persona states a fact the claims ledger does not hold", "Source the fact and add it to the claims ledger, or cut it."],
|
|
131
|
+
};
|
|
132
|
+
const findings = verdict.breaks.map((b) => {
|
|
133
|
+
const [message, fix] = said[b.kind];
|
|
134
|
+
return t.finding("judge-persona-break", `${message}: ${b.why.trim()}`, fix, t.lineOf(b.evidence));
|
|
135
|
+
});
|
|
136
|
+
return { status: findings.length ? "fail" : "pass", findings };
|
|
137
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The reader: a judge reads the draft as the person the audience block describes, knowing only
|
|
2
|
+
// what they know, and reports every place it got lost, where (if anywhere) it stopped reading, and
|
|
3
|
+
// what it would do next. hyperspec writes the packet, checks every quoted span against the draft
|
|
4
|
+
// (src/judge.mjs's evidence rule) and derives the status: pass when the reader read to the end and
|
|
5
|
+
// would take the next step now. A place the reader got lost is a warning, not a failure: it is where
|
|
6
|
+
// to look first, and a reader who got lost and kept going still arrived.
|
|
7
|
+
|
|
8
|
+
import { str } from "../placeholder.mjs";
|
|
9
|
+
|
|
10
|
+
export const name = "reader";
|
|
11
|
+
|
|
12
|
+
export const READER_INSTRUCTIONS = [
|
|
13
|
+
"Read inputs.draft as the reader inputs.audience describes: where and how they read it, knowing only what they know and believing what they believe now.",
|
|
14
|
+
"For every place you got lost (a term you do not know, a step that does not follow, a sentence you had to read twice), add an entry to lost_at: the passage as evidence, copied verbatim from inputs.draft, at least three whole words, and why.",
|
|
15
|
+
"If you would stop reading before the end, set stopped_at to the passage where you stopped, quoted the same way, and why; if you would read to the end, set stopped_at to null.",
|
|
16
|
+
"Then say in next_step what you would do next, in your own words, and whether you would do it now (would_take_next_step).",
|
|
17
|
+
"Answer only in the verdict shape given in verdict_schema.",
|
|
18
|
+
].join(" ");
|
|
19
|
+
|
|
20
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
21
|
+
const texts = (v) => (Array.isArray(v) ? v.map(str).filter(Boolean) : []);
|
|
22
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
|
|
23
|
+
|
|
24
|
+
// null when the reader applies: it needs a written audience block whose check names a rubric.
|
|
25
|
+
export function skipReason(spec) {
|
|
26
|
+
const audience = spec.data?.writing?.audience;
|
|
27
|
+
if (!isObject(audience)) return "writing.audience is not written (deferred)";
|
|
28
|
+
if (!str(audience.check?.rubric)) return "writing.audience.check has no rubric";
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const passageSchema = (what) => ({
|
|
33
|
+
type: "object",
|
|
34
|
+
required: ["evidence", "why"],
|
|
35
|
+
properties: {
|
|
36
|
+
evidence: { type: "string", description: `the passage ${what}, copied verbatim from the draft, at least three words` },
|
|
37
|
+
why: { type: "string" },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// The packet's rubric (audience.check.rubric, verbatim), inputs (the audience block's reader fields
|
|
42
|
+
// and the draft) and verdict schema. No answer key.
|
|
43
|
+
export function packet(spec, draft) {
|
|
44
|
+
const a = spec.data.writing.audience;
|
|
45
|
+
return {
|
|
46
|
+
rubric: a.check.rubric,
|
|
47
|
+
inputs: {
|
|
48
|
+
audience: {
|
|
49
|
+
who: str(a.who),
|
|
50
|
+
funnel_now: str(a.funnel_now),
|
|
51
|
+
knows: texts(a.knows),
|
|
52
|
+
terms: texts(a.terms),
|
|
53
|
+
believes_now: str(a.believes_now),
|
|
54
|
+
wants: str(a.wants),
|
|
55
|
+
reads_on: str(a.reads_on),
|
|
56
|
+
reader: str(a.reader),
|
|
57
|
+
},
|
|
58
|
+
draft: draft.text,
|
|
59
|
+
},
|
|
60
|
+
verdict_schema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
required: ["lost_at", "stopped_at", "would_take_next_step", "next_step"],
|
|
63
|
+
properties: {
|
|
64
|
+
lost_at: { type: "array", description: "every place the reader got lost; empty when nowhere", items: passageSchema("where the reader got lost") },
|
|
65
|
+
stopped_at: { anyOf: [{ type: "null" }, passageSchema("where the reader stopped reading")], description: "null when the reader read to the end" },
|
|
66
|
+
would_take_next_step: { type: "boolean", description: "whether the reader would take next_step now" },
|
|
67
|
+
next_step: { type: "string", description: "what the reader would do next, in their own words" },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
key: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The problems with one quoted passage ({ evidence, why }) at `at`.
|
|
75
|
+
function passageProblems(p, at, t) {
|
|
76
|
+
if (!isObject(p)) return [t.shape(`${at} is not an object`, "Write it as { evidence, why }.")];
|
|
77
|
+
const out = [];
|
|
78
|
+
if (!nonEmpty(p.why)) out.push(t.shape(`${at}.why is missing or empty`, "Say why, in a sentence."));
|
|
79
|
+
out.push(...t.evidence(p.evidence, `${at}.evidence`));
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Every problem with the verdict, as findings; every evidence span must be in the draft.
|
|
84
|
+
export function validate(verdict, pkt, t) {
|
|
85
|
+
if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { lost_at, stopped_at, would_take_next_step, next_step }.")];
|
|
86
|
+
const out = [];
|
|
87
|
+
if (!Array.isArray(verdict.lost_at)) out.push(t.shape("lost_at is missing or not a list", "Give lost_at: one { evidence, why } per place the reader got lost, or [] when there is none."));
|
|
88
|
+
else verdict.lost_at.forEach((p, i) => out.push(...passageProblems(p, `lost_at[${i}]`, t)));
|
|
89
|
+
if (!Object.hasOwn(verdict, "stopped_at")) out.push(t.shape("stopped_at is missing", "Set stopped_at to { evidence, why } where the reader stopped, or to null when they read to the end."));
|
|
90
|
+
else if (verdict.stopped_at !== null) out.push(...passageProblems(verdict.stopped_at, "stopped_at", t));
|
|
91
|
+
if (typeof verdict.would_take_next_step !== "boolean") out.push(t.shape("would_take_next_step is missing or not true or false", "Set would_take_next_step to the JSON boolean true or false."));
|
|
92
|
+
if (!nonEmpty(verdict.next_step)) out.push(t.shape("next_step is missing or empty", "Say what the reader would do next, in their words."));
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Passes when the reader read to the end and would take the next step now; every place it got lost
|
|
97
|
+
// is a warning at the passage's line.
|
|
98
|
+
export function derive(verdict, pkt, t) {
|
|
99
|
+
const findings = verdict.lost_at.map((p) => ({
|
|
100
|
+
...t.finding("judge-reader-lost", `the reader got lost here: ${p.why.trim()}`, "Define, cut or reorder what lost this reader, for what they know now.", t.lineOf(p.evidence)),
|
|
101
|
+
severity: "warn",
|
|
102
|
+
}));
|
|
103
|
+
if (verdict.stopped_at) {
|
|
104
|
+
findings.push(t.finding("judge-reader-stopped", `the reader stopped reading here: ${verdict.stopped_at.why.trim()}`, "Revise the passage the reader stopped at so this reader keeps going, then prepare and judge again.", t.lineOf(verdict.stopped_at.evidence)));
|
|
105
|
+
}
|
|
106
|
+
if (!verdict.would_take_next_step) {
|
|
107
|
+
findings.push(t.finding("judge-reader-next-step", `the reader would not take the next step now (their next step: ${verdict.next_step.trim()})`, "Revise the draft so this reader wants to act on it now, then prepare and judge again."));
|
|
108
|
+
}
|
|
109
|
+
const status = verdict.stopped_at || !verdict.would_take_next_step ? "fail" : "pass";
|
|
110
|
+
return { status, findings };
|
|
111
|
+
}
|