@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,360 @@
|
|
|
1
|
+
// Attribution (fiction only): a blind test of whether the characters' voices can be told apart. The
|
|
2
|
+
// packet holds the draft's dialogue lines whose speaker hyperspec can determine mechanically, with
|
|
3
|
+
// the speaker and all the narration around them removed, plus each character's speech block,
|
|
4
|
+
// golden and rejected lines. The judge names a speaker for every line; record compares the answers
|
|
5
|
+
// with the true speakers, which live only in the answer key (attribution.key.json, rebuilt at record
|
|
6
|
+
// time, never read back). The station passes when accuracy averaged per speaker is 80 percent or
|
|
7
|
+
// more, so naming one speaker for every line cannot pass.
|
|
8
|
+
//
|
|
9
|
+
// Dialogue lines. The draft's double-quoted spans, straight ("...") or curly (U+201C ... U+201D),
|
|
10
|
+
// paired within one paragraph with code masked first: the quotes station's own quotedSpans
|
|
11
|
+
// (src/stations/quotes.mjs). A span with no letter or digit is not a line. A quote split by a
|
|
12
|
+
// speech tag ("Twenty minutes," Ines said, "then we fold it.") is one line: the first part ends in
|
|
13
|
+
// a comma, and the narration between the parts is exactly one speech tag and its comma, nothing
|
|
14
|
+
// else. Narration that holds anything more ("Ines said, and Theo muttered,", "Ines said, wiping her
|
|
15
|
+
// hands,") joins nothing: the first part keeps the tag that opens it, and the second part, whose
|
|
16
|
+
// narration is all of it, is left out, since what else that narration holds (a second speaker by
|
|
17
|
+
// a beat, a pronoun or a verb off the list) cannot be read mechanically.
|
|
18
|
+
// Lines keep draft order and are numbered L1..Ln over the lines that are attributed.
|
|
19
|
+
//
|
|
20
|
+
// The true speaker comes ONLY from a speech tag: narration in the same paragraph that
|
|
21
|
+
// sits right against the quote, directly after its closing mark (`"...," Ines said`, `"...," said
|
|
22
|
+
// Ines`) or directly before its opening mark, ending in a comma or colon (`Ines said, "..."`). A
|
|
23
|
+
// tag is a subject next to a verb from SPEECH_VERBS; the inverted form, verb before name ("said
|
|
24
|
+
// Ines"), only with INVERTED_VERBS, since "Ines told Theo" names Theo as the one spoken to. The
|
|
25
|
+
// subject can be:
|
|
26
|
+
// - a character's id or name, whole words, any case ("Ines said", "said Ines"); a possessive
|
|
27
|
+
// ("Ines's") is not a name, and an action beat ("Theo nodded") is not a tag;
|
|
28
|
+
// - "I", when persona.identity is character:<id>: the narrator speaks;
|
|
29
|
+
// - "she" or "he" (lower case after a quote, either case before one), only when exactly two
|
|
30
|
+
// characters have speech blocks and one of them is the narrator: the other one speaks.
|
|
31
|
+
// Anything else leaves the line out, counted in the packet (excluded) and listed in the key with its
|
|
32
|
+
// reason. The key must never be wrong, so every doubt excludes: no tag, a tag whose subject cannot
|
|
33
|
+
// be resolved to a character with a speech block, and any line whose tags yield two different
|
|
34
|
+
// candidate speakers, by any of the rules above.
|
|
35
|
+
//
|
|
36
|
+
// A line of three or more words that contains, or is contained in, any character's golden or
|
|
37
|
+
// rejected line (compared as lower-cased words) is left out too: shown beside the
|
|
38
|
+
// speech lines, it would give its speaker away.
|
|
39
|
+
|
|
40
|
+
import { str } from "../placeholder.mjs";
|
|
41
|
+
import { quotedSpans } from "../stations/quotes.mjs";
|
|
42
|
+
import { lineAt, maskCode } from "../stations/util.mjs";
|
|
43
|
+
|
|
44
|
+
export const name = "attribution";
|
|
45
|
+
|
|
46
|
+
// Pass at 80 percent or more, compared exactly: mean per-speaker accuracy >= 4 / 5.
|
|
47
|
+
export const PASS_NUMERATOR = 4;
|
|
48
|
+
export const PASS_DENOMINATOR = 5;
|
|
49
|
+
|
|
50
|
+
// The verbs that make narration a speech tag, one closed list. Everything else ("nodded", "laughed",
|
|
51
|
+
// "did not look up") is an action beat and names nobody.
|
|
52
|
+
export const SPEECH_VERBS = Object.freeze([
|
|
53
|
+
"said", "says", "asked", "asks", "told", "tells", "replied", "replies", "called", "calls",
|
|
54
|
+
"whispered", "whispers", "shouted", "shouts", "answered", "answers", "added", "adds", "went on", "goes on",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// The speech verbs that also make an inverted tag, verb before name ("said Ines"). The
|
|
58
|
+
// rest (told, asked, called, answered, added) take a person as their object as often as they tag
|
|
59
|
+
// speech ("Ines told Theo"), so for them only the name-then-verb form counts: an inversion there
|
|
60
|
+
// would credit the person spoken to.
|
|
61
|
+
export const INVERTED_VERBS = Object.freeze(["said", "says", "replied", "replies", "whispered", "whispers", "shouted", "shouts", "went on", "goes on"]);
|
|
62
|
+
|
|
63
|
+
// Why a dialogue line was left out, as the key records it.
|
|
64
|
+
export const EXCLUDED = Object.freeze({
|
|
65
|
+
noTag: "no speech tag",
|
|
66
|
+
unknown: "the speech tag names no character with a speech block",
|
|
67
|
+
conflict: "speech tags name more than one speaker",
|
|
68
|
+
split: "follows a split quote whose narration is more than one speech tag",
|
|
69
|
+
repeats: "repeats a golden or rejected line",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const ATTRIBUTION_INSTRUCTIONS = [
|
|
73
|
+
"Each entry in inputs.lines is one line of dialogue from the draft, with its speaker and the narration around it removed.",
|
|
74
|
+
"Using only how each character in inputs.characters speaks (their speech block, golden_lines and rejected_lines), name who says each line.",
|
|
75
|
+
"Judge from the packet's inputs alone: do not open the spec, the draft or any other file the packet names.",
|
|
76
|
+
"Answer in lines: one { id, speaker } per line id, each id exactly once, where speaker is a character id from inputs.characters.",
|
|
77
|
+
"Answer only in the verdict shape given in verdict_schema.",
|
|
78
|
+
].join(" ");
|
|
79
|
+
|
|
80
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
81
|
+
const texts = (v) => (Array.isArray(v) ? v.map(str).filter(Boolean) : []);
|
|
82
|
+
const WORD = "\\p{L}\\p{N}";
|
|
83
|
+
const HAS_WORD = new RegExp(`[${WORD}]`, "u");
|
|
84
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
85
|
+
const verbAlternation = (verbs) => `(?:${verbs.map((v) => v.split(" ").map(escapeRe).join("\\s+")).join("|")})`;
|
|
86
|
+
const VERB = verbAlternation(SPEECH_VERBS);
|
|
87
|
+
const INVERTED = verbAlternation(INVERTED_VERBS);
|
|
88
|
+
|
|
89
|
+
// A name or verb ends at a non-word character, and an apostrophe is not an end: "Ines's" is not "Ines".
|
|
90
|
+
const END = `(?![${WORD}'’])`;
|
|
91
|
+
const START = `(?<![${WORD}'’])`;
|
|
92
|
+
const UNKNOWN = Symbol("unknown speaker");
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
// A character's id or name as the pattern source of its words (split on anything that is not a
|
|
96
|
+
// letter or digit, so the id "old-man" reads as "old man"), joined in the text by whitespace,
|
|
97
|
+
// hyphens or underscores; null when the value has no words.
|
|
98
|
+
function nameSource(value) {
|
|
99
|
+
const words = String(value).split(new RegExp(`[^${WORD}]+`, "u")).filter(Boolean);
|
|
100
|
+
return words.length ? words.map(escapeRe).join("[\\s_-]+") : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The tag matchers for one cast: each { after, before, only, speaker }, where after is anchored at
|
|
104
|
+
// the start of the narration following a quote, before at the end of the narration preceding one,
|
|
105
|
+
// and only matches narration that is that one tag and its comma and nothing else (the tag that
|
|
106
|
+
// joins a split quote);
|
|
107
|
+
// speaker is a character id, or UNKNOWN when the subject cannot be resolved (a first-person tag with
|
|
108
|
+
// no narrator, a pronoun outside a two-hander). cast: [{ id, name, speech }]; narrator: an id or null.
|
|
109
|
+
function tagMatchers(cast, narrator) {
|
|
110
|
+
const speaking = cast.filter((c) => c.speech);
|
|
111
|
+
const speakerOr = (id) => (id && speaking.some((c) => c.id === id) ? id : UNKNOWN);
|
|
112
|
+
const narratorId = narrator && cast.some((c) => c.id === narrator) ? narrator : null;
|
|
113
|
+
const other = narratorId && speaking.length === 2 && speaking.some((c) => c.id === narratorId) ? speaking.find((c) => c.id !== narratorId).id : null;
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const c of cast) {
|
|
116
|
+
for (const src of [nameSource(c.id), c.name ? nameSource(c.name) : null].filter(Boolean)) {
|
|
117
|
+
const speaker = speakerOr(c.id);
|
|
118
|
+
out.push({ after: new RegExp(`^\\s*(?:${src}\\s+${VERB}|${INVERTED}\\s+${src})${END}`, "iu"), before: new RegExp(`${START}(?:${src}\\s+${VERB}|${INVERTED}\\s+${src})\\s*[,:]\\s*$`, "iu"), only: new RegExp(`^\\s*(?:${src}\\s+${VERB}|${INVERTED}\\s+${src})\\s*,\\s*$`, "iu"), speaker });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
out.push({ after: new RegExp(`^\\s*I\\s+${VERB}${END}`, "u"), before: new RegExp(`${START}I\\s+${VERB}\\s*[,:]\\s*$`, "u"), only: new RegExp(`^\\s*I\\s+${VERB}\\s*,\\s*$`, "u"), speaker: speakerOr(narratorId) });
|
|
122
|
+
out.push({ after: new RegExp(`^\\s*(?:she|he)\\s+${VERB}${END}`, "u"), before: new RegExp(`${START}(?:[Ss]he|[Hh]e)\\s+${VERB}\\s*[,:]\\s*$`, "u"), only: new RegExp(`^\\s*(?:she|he)\\s+${VERB}\\s*,\\s*$`, "u"), speaker: speakerOr(other) });
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The speakers a stretch of narration tags: `side` is "after" (it follows a quote) or "before" (it
|
|
127
|
+
// precedes one).
|
|
128
|
+
function tagged(matchers, narration, side) {
|
|
129
|
+
return matchers.filter((m) => m[side].test(narration)).map((m) => m.speaker);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A text as its lower-cased words, space-joined, for comparing a line with the speech lines.
|
|
133
|
+
const wordsKey = (text) => (String(text).toLowerCase().replace(/[‘’]/g, "'").match(/[\p{L}\p{N}]+(?:'[\p{L}\p{N}]+)*/gu) ?? []).join(" ");
|
|
134
|
+
|
|
135
|
+
// Whether a line repeats a golden or rejected line: three or more words, contained in one or
|
|
136
|
+
// containing one, compared as words.
|
|
137
|
+
function repeatsSpeechLine(text, speechKeys) {
|
|
138
|
+
const key = wordsKey(text);
|
|
139
|
+
if (key.split(" ").filter(Boolean).length < 3) return false;
|
|
140
|
+
const k = ` ${key} `;
|
|
141
|
+
return speechKeys.some((s) => s && (` ${s} `.includes(k) || k.includes(` ${s} `)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The draft's dialogue lines split into the attributed and the left out: { lines: [{ id, text,
|
|
145
|
+
// speaker, line }], excluded: [{ line, text, reason }] }. cast: [{ id, name, speech, golden,
|
|
146
|
+
// rejected }] (speech null for a character with no speech block); narrator: the persona's character
|
|
147
|
+
// id, or null.
|
|
148
|
+
export function dialogueLines(draftText, cast, { narrator = null } = {}) {
|
|
149
|
+
const masked = maskCode(draftText);
|
|
150
|
+
const matchers = tagMatchers(cast, narrator);
|
|
151
|
+
const speechKeys = cast.flatMap((c) => [...(c.golden ?? []), ...(c.rejected ?? [])]).map(wordsKey);
|
|
152
|
+
|
|
153
|
+
// Every quoted span once, grouped by paragraph in draft order; spans with no word are dropped
|
|
154
|
+
// here, after they have bounded their neighbours' narration.
|
|
155
|
+
const paragraphs = [];
|
|
156
|
+
for (const s of quotedSpans(masked)) {
|
|
157
|
+
const last = paragraphs.at(-1);
|
|
158
|
+
if (last && last.start === s.para.start) last.spans.push(s);
|
|
159
|
+
else paragraphs.push({ start: s.para.start, end: s.para.end, spans: [s] });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lines = [];
|
|
163
|
+
const excluded = [];
|
|
164
|
+
for (const para of paragraphs) {
|
|
165
|
+
const { spans } = para;
|
|
166
|
+
const narrationBefore = (i) => masked.slice(i === 0 ? para.start : spans[i - 1].end, spans[i].start);
|
|
167
|
+
const narrationAfter = (i) => masked.slice(spans[i].end, i + 1 < spans.length ? spans[i + 1].start : para.end);
|
|
168
|
+
// Whether span k continues a split quote: the part before it has a word and ends in a comma,
|
|
169
|
+
// span k has a word, and the narration between them ends in a comma.
|
|
170
|
+
const splitAt = (k) => k > 0 && k < spans.length && HAS_WORD.test(spans[k - 1].inner) && /,\s*$/.test(spans[k - 1].inner)
|
|
171
|
+
&& HAS_WORD.test(spans[k].inner) && /,\s*$/.test(narrationBefore(k));
|
|
172
|
+
for (let i = 0; i < spans.length; i++) {
|
|
173
|
+
if (!HAS_WORD.test(spans[i].inner)) continue;
|
|
174
|
+
// Merge a quote split by a speech tag: this part ends in a comma, and the narration up to the
|
|
175
|
+
// next part is exactly one tag and its comma.
|
|
176
|
+
let j = i;
|
|
177
|
+
while (splitAt(j + 1) && tagged(matchers, narrationAfter(j), "only").length) j += 1;
|
|
178
|
+
const speakers = new Set([...tagged(matchers, narrationBefore(i), "before")]);
|
|
179
|
+
for (let k = i; k <= j; k++) for (const s of tagged(matchers, narrationAfter(k), "after")) speakers.add(s);
|
|
180
|
+
const text = spans.slice(i, j + 1).map((s) => s.inner.replace(/\s+/g, " ").trim()).join(" ");
|
|
181
|
+
const line = lineAt(draftText, spans[i].start);
|
|
182
|
+
let reason = null;
|
|
183
|
+
if (repeatsSpeechLine(text, speechKeys)) reason = EXCLUDED.repeats;
|
|
184
|
+
// The second part of a split quote whose narration was more than one tag: that narration can
|
|
185
|
+
// hold a second speaker hyperspec cannot see, so it is never keyed, whatever tag ends it.
|
|
186
|
+
else if (splitAt(i)) reason = speakers.size ? EXCLUDED.split : EXCLUDED.noTag;
|
|
187
|
+
else if (!speakers.size) reason = EXCLUDED.noTag;
|
|
188
|
+
else if (speakers.size > 1) reason = EXCLUDED.conflict;
|
|
189
|
+
else if ([...speakers][0] === UNKNOWN) reason = EXCLUDED.unknown;
|
|
190
|
+
if (reason) excluded.push({ line, text, reason });
|
|
191
|
+
else lines.push({ id: `L${lines.length + 1}`, text, speaker: [...speakers][0], line });
|
|
192
|
+
i = j;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { lines, excluded };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Every character entry with an id, as { id, name, speech, golden, rejected, raw }: speech is the
|
|
199
|
+
// character's speech block when it is an object with at least one entry, else null.
|
|
200
|
+
function castOf(spec) {
|
|
201
|
+
const list = Array.isArray(spec.data?.writing?.characters) ? spec.data.writing.characters : [];
|
|
202
|
+
return list.filter((c) => isObject(c) && str(c.id)).map((c) => {
|
|
203
|
+
const speech = isObject(c.speech) && (texts(c.speech.uses).length || texts(c.speech.never).length || str(c.speech.rhythm)) ? c.speech : null;
|
|
204
|
+
return { id: str(c.id), name: str(c.name), speech, golden: texts(c.golden_lines), rejected: texts(c.rejected_lines), raw: c };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The narrator: persona.identity character:<id>, or null.
|
|
209
|
+
function narratorOf(spec) {
|
|
210
|
+
const m = /^character:(.+)$/.exec(str(spec.data?.writing?.persona?.identity));
|
|
211
|
+
return m ? m[1].trim() : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// "2 with no speech tag, 1 repeating a golden or rejected line": how many lines were left out, why.
|
|
215
|
+
function excludedBreakdown(excluded) {
|
|
216
|
+
const counts = new Map();
|
|
217
|
+
for (const e of excluded) counts.set(e.reason, (counts.get(e.reason) ?? 0) + 1);
|
|
218
|
+
const said = { [EXCLUDED.noTag]: "with no speech tag", [EXCLUDED.unknown]: "whose tag names no character with a speech block", [EXCLUDED.conflict]: "whose tags name more than one speaker", [EXCLUDED.split]: "following a split quote whose narration is more than a tag", [EXCLUDED.repeats]: "repeating a golden or rejected line" };
|
|
219
|
+
return [...counts].map(([reason, n]) => `${n} ${said[reason]}`).join(", ");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The rubric: the distinct check.rubric texts of the characters in the test, in their order, one per
|
|
223
|
+
// line (a story whose characters share one rubric gets that rubric verbatim), or "" when none has one.
|
|
224
|
+
function rubricOf(chars) {
|
|
225
|
+
return [...new Set(chars.map((c) => str(c.raw.check?.rubric)).filter(Boolean))].join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function plan(spec, draft) {
|
|
229
|
+
if (str(spec.data?.fiction) !== "true") return { skip: "the spec is not fiction; attribution applies only with fiction: true" };
|
|
230
|
+
const cast = castOf(spec);
|
|
231
|
+
if (!cast.length) return { skip: "writing.characters has no character" };
|
|
232
|
+
const speaking = cast.filter((c) => c.speech);
|
|
233
|
+
if (speaking.length < 2) return { skip: `attribution needs at least two characters with a speech block; writing.characters has ${speaking.length}` };
|
|
234
|
+
const rubric = rubricOf(speaking);
|
|
235
|
+
if (!rubric) return { skip: "no character's check has a rubric" };
|
|
236
|
+
const { lines, excluded } = dialogueLines(draft.text, cast, { narrator: narratorOf(spec) });
|
|
237
|
+
const total = lines.length + excluded.length;
|
|
238
|
+
if (!total) return { skip: "the draft has no dialogue line (a double-quoted span)" };
|
|
239
|
+
if (!lines.length) return { skip: `none of the draft's ${total} dialogue line${total === 1 ? "" : "s"} can be attributed mechanically (${excludedBreakdown(excluded)})` };
|
|
240
|
+
const speakers = [...new Set(lines.map((l) => l.speaker))];
|
|
241
|
+
if (speakers.length < 2) {
|
|
242
|
+
return { skip: `attribution needs attributable lines from at least two speakers; all ${lines.length} of the draft's attributable dialogue line${lines.length === 1 ? " is" : "s are"} ${speakers[0]}'s${excluded.length ? ` (${excluded.length} left out: ${excludedBreakdown(excluded)})` : ""}` };
|
|
243
|
+
}
|
|
244
|
+
return { speaking, rubric, lines, excluded };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// null when attribution applies: fiction, at least two characters with a speech block, a rubric on
|
|
248
|
+
// one of them, and attributable lines from at least two speakers.
|
|
249
|
+
export function skipReason(spec, draft) {
|
|
250
|
+
return plan(spec, draft).skip ?? null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The packet (the lines' text only, and how many lines were left out) and the key (every line's
|
|
254
|
+
// true speaker and draft line, and the lines left out with why).
|
|
255
|
+
export function packet(spec, draft) {
|
|
256
|
+
const p = plan(spec, draft);
|
|
257
|
+
const ids = p.lines.map((l) => l.id);
|
|
258
|
+
const charIds = p.speaking.map((c) => c.id);
|
|
259
|
+
return {
|
|
260
|
+
rubric: p.rubric,
|
|
261
|
+
inputs: {
|
|
262
|
+
characters: p.speaking.map((c) => ({
|
|
263
|
+
id: c.id,
|
|
264
|
+
...(c.name ? { name: c.name } : {}),
|
|
265
|
+
speech: { uses: texts(c.speech.uses), never: texts(c.speech.never), rhythm: str(c.speech.rhythm) },
|
|
266
|
+
golden_lines: c.golden,
|
|
267
|
+
rejected_lines: c.rejected,
|
|
268
|
+
})),
|
|
269
|
+
lines: p.lines.map((l) => ({ id: l.id, text: l.text })),
|
|
270
|
+
excluded: p.excluded.length,
|
|
271
|
+
},
|
|
272
|
+
verdict_schema: {
|
|
273
|
+
type: "object",
|
|
274
|
+
required: ["lines"],
|
|
275
|
+
properties: {
|
|
276
|
+
lines: {
|
|
277
|
+
type: "array",
|
|
278
|
+
description: "one entry per line id, each exactly once",
|
|
279
|
+
items: {
|
|
280
|
+
type: "object",
|
|
281
|
+
required: ["id", "speaker"],
|
|
282
|
+
properties: { id: { enum: ids }, speaker: { enum: charIds } },
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
key: {
|
|
288
|
+
station: name,
|
|
289
|
+
lines: p.lines.map((l) => ({ id: l.id, speaker: l.speaker, line: l.line })),
|
|
290
|
+
excluded: p.excluded,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// A speaker as the verdict wrote it, resolved to a character id: an id or a name, trimmed, any case.
|
|
296
|
+
function speakerId(value, characters) {
|
|
297
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
298
|
+
const v = value.trim().toLowerCase();
|
|
299
|
+
const c = characters.find((ch) => ch.id.toLowerCase() === v || (ch.name && ch.name.toLowerCase() === v));
|
|
300
|
+
return c ? c.id : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Every problem with the verdict, as findings: every line id exactly once, each with a known speaker.
|
|
304
|
+
export function validate(verdict, pkt, t) {
|
|
305
|
+
if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { lines }.")];
|
|
306
|
+
if (!Array.isArray(verdict.lines)) return [t.shape("lines is missing or not a list", "Give lines: one { id, speaker } per line id.")];
|
|
307
|
+
const expected = pkt.inputs.lines.map((l) => l.id);
|
|
308
|
+
const known = pkt.inputs.characters.map((c) => c.id);
|
|
309
|
+
const out = [];
|
|
310
|
+
const seen = new Map();
|
|
311
|
+
verdict.lines.forEach((l, i) => {
|
|
312
|
+
const at = `lines[${i}]`;
|
|
313
|
+
if (!isObject(l)) { out.push(t.shape(`${at} is not an object`, "Each line is { id, speaker }.")); return; }
|
|
314
|
+
if (typeof l.id !== "string") out.push(t.shape(`${at}.id is not a string`, "Give the line's id, as the packet lists it."));
|
|
315
|
+
else seen.set(l.id, (seen.get(l.id) ?? 0) + 1);
|
|
316
|
+
if (typeof l.speaker !== "string") out.push(t.shape(`${at}.speaker is not a string`, "Give the speaker's character id."));
|
|
317
|
+
else if (!speakerId(l.speaker, pkt.inputs.characters)) out.push(t.finding("judge-attribution-speaker-unknown", `${at}.speaker "${l.speaker}" is not a character in the packet`, `Name one of: ${known.join(", ")}.`));
|
|
318
|
+
});
|
|
319
|
+
for (const id of expected) if (!seen.has(id)) out.push(t.finding("judge-attribution-line-missing", `line ${id} has no entry in lines`, "Attribute every line the packet lists, each exactly once."));
|
|
320
|
+
for (const [id, n] of seen) {
|
|
321
|
+
if (!expected.includes(id)) out.push(t.finding("judge-attribution-line-unknown", `lines names ${id}, which is not a line in the packet`, `Attribute only the packet's lines: ${expected.join(", ")}.`));
|
|
322
|
+
else if (n > 1) out.push(t.finding("judge-attribution-line-duplicate", `line ${id} appears ${n} times in lines`, "Attribute each line exactly once."));
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const pctFloor = (num, den) => Number((BigInt(num) * 100n) / BigInt(den));
|
|
328
|
+
|
|
329
|
+
// Accuracy against the rebuilt key, per speaker: each speaker's share of their own
|
|
330
|
+
// lines named correctly, averaged over the speakers with lines. Pass when that mean is 80 percent or
|
|
331
|
+
// more, compared exactly. Every misattributed line is a warning at its draft line; a mean under 80
|
|
332
|
+
// percent is the failure. summary reports every speaker's accuracy as a fraction and a percentage,
|
|
333
|
+
// and the mean (percentages round down, so a failing mean never prints as 80%).
|
|
334
|
+
export function derive(verdict, pkt, t, key) {
|
|
335
|
+
const said = new Map(verdict.lines.map((l) => [l.id, speakerId(l.speaker, pkt.inputs.characters)]));
|
|
336
|
+
const order = pkt.inputs.characters.map((c) => c.id).filter((id) => key.lines.some((l) => l.speaker === id));
|
|
337
|
+
const per = new Map(order.map((id) => [id, { right: 0, total: 0 }]));
|
|
338
|
+
const findings = [];
|
|
339
|
+
for (const l of key.lines) {
|
|
340
|
+
const got = said.get(l.id);
|
|
341
|
+
const s = per.get(l.speaker);
|
|
342
|
+
s.total += 1;
|
|
343
|
+
if (got === l.speaker) { s.right += 1; continue; }
|
|
344
|
+
findings.push({ ...t.finding("judge-attribution-miss", `${l.id} was attributed to ${got}; its speech tag names ${l.speaker}`, `Make this line sound like ${l.speaker} (their speech block and golden lines), and unlike ${got}.`, l.line), severity: "warn" });
|
|
345
|
+
}
|
|
346
|
+
// mean = (sum over speakers of right_s / total_s) / k, kept as one exact fraction num / den.
|
|
347
|
+
const totals = order.map((id) => BigInt(per.get(id).total));
|
|
348
|
+
const product = totals.reduce((a, b) => a * b, 1n);
|
|
349
|
+
const num = order.reduce((acc, id) => acc + BigInt(per.get(id).right) * (product / BigInt(per.get(id).total)), 0n);
|
|
350
|
+
const den = product * BigInt(order.length);
|
|
351
|
+
const pass = num * BigInt(PASS_DENOMINATOR) >= den * BigInt(PASS_NUMERATOR);
|
|
352
|
+
const mean = Number((num * 100n) / den);
|
|
353
|
+
const perText = order.map((id) => { const s = per.get(id); return `${id} ${s.right}/${s.total} (${pctFloor(s.right, s.total)}%)`; }).join(", ");
|
|
354
|
+
if (!pass) {
|
|
355
|
+
findings.unshift(t.finding("judge-attribution-accuracy", `the judge's accuracy averaged over speakers is ${mean}% (${perText}), under the 80% that tells the voices apart`, "Make the voices more distinct, each line in its character's speech (see the misattributed lines), then prepare and judge again."));
|
|
356
|
+
}
|
|
357
|
+
const left = key.excluded.length;
|
|
358
|
+
const summary = `accuracy per speaker: ${perText}; mean ${mean}%, passing at 80%${left ? `; ${left} dialogue line${left === 1 ? "" : "s"} left out (${excludedBreakdown(key.excluded)})` : ""}`;
|
|
359
|
+
return { status: pass ? "pass" : "fail", findings, summary };
|
|
360
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The doctor: a judge grades the draft against every goal condition and says whether this reader
|
|
2
|
+
// would take the next step now. hyperspec never calls the judge; it writes what the judge needs
|
|
3
|
+
// (packet), checks what the judge wrote (validate), and derives the station's status from it
|
|
4
|
+
// (derive). See src/judge.mjs for the framework and the evidence rule.
|
|
5
|
+
|
|
6
|
+
import { str } from "../placeholder.mjs";
|
|
7
|
+
|
|
8
|
+
export const name = "doctor";
|
|
9
|
+
|
|
10
|
+
export const DOCTOR_INSTRUCTIONS = [
|
|
11
|
+
"Grade the draft (inputs.draft) against each condition in inputs.conditions: a condition passes unless the draft does what its fails_when describes.",
|
|
12
|
+
"For every judgment, quote the draft as evidence: each evidence field is a span copied verbatim from inputs.draft.",
|
|
13
|
+
"Then answer whether this reader, having read the draft, would take the next step (inputs.goal.next_if_worked) now, and quote the passage that decided it.",
|
|
14
|
+
"Answer only in the verdict shape given in verdict_schema: one entry per condition id, each exactly once, with pass and would_take_next_step as true or false, and a note saying why for every condition that fails.",
|
|
15
|
+
].join(" ");
|
|
16
|
+
|
|
17
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
18
|
+
const text = (v) => (typeof v === "string" ? v : "");
|
|
19
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
20
|
+
|
|
21
|
+
// The goal's condition ids, in the order the spec lists them, each once. Lint (test 2) already
|
|
22
|
+
// requires 5 to 10 distinct ids that exist under requirements.
|
|
23
|
+
function conditionIds(goal) {
|
|
24
|
+
return [...new Set(list(goal?.conditions).map(str).filter(Boolean))];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// null when the doctor applies to this spec, otherwise the reason it does not: it needs a written
|
|
28
|
+
// goal block (a deferred one has nothing to grade against) whose check names a rubric.
|
|
29
|
+
export function skipReason(spec) {
|
|
30
|
+
const goal = spec.data?.writing?.goal;
|
|
31
|
+
if (!isObject(goal)) return "writing.goal is not written (deferred)";
|
|
32
|
+
if (!str(goal.check?.rubric)) return "writing.goal.check has no rubric";
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// What goes in the packet beyond the framework's own fields: the rubric (goal.check.rubric,
|
|
37
|
+
// verbatim), the inputs and the verdict schema. No answer key: the doctor has nothing hidden.
|
|
38
|
+
export function packet(spec, draft) {
|
|
39
|
+
const goal = spec.data.writing.goal;
|
|
40
|
+
const change = isObject(goal.change) ? goal.change : {};
|
|
41
|
+
const requirements = new Map(list(spec.data.requirements).filter(isObject).map((r) => [str(r.id), r]));
|
|
42
|
+
const ids = conditionIds(goal);
|
|
43
|
+
return {
|
|
44
|
+
rubric: goal.check.rubric,
|
|
45
|
+
inputs: {
|
|
46
|
+
goal: {
|
|
47
|
+
from: text(goal.from),
|
|
48
|
+
to: text(goal.to),
|
|
49
|
+
next_if_worked: text(goal.next_if_worked),
|
|
50
|
+
change: { kind: text(change.kind), text: text(change.text) },
|
|
51
|
+
},
|
|
52
|
+
conditions: ids.map((id) => ({ id, text: text(requirements.get(id)?.text), fails_when: text(requirements.get(id)?.fails_when) })),
|
|
53
|
+
draft: draft.text,
|
|
54
|
+
},
|
|
55
|
+
verdict_schema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
required: ["conditions", "would_take_next_step", "evidence"],
|
|
58
|
+
properties: {
|
|
59
|
+
conditions: {
|
|
60
|
+
type: "array",
|
|
61
|
+
description: "one entry per condition id, each exactly once",
|
|
62
|
+
items: {
|
|
63
|
+
type: "object",
|
|
64
|
+
required: ["id", "pass", "evidence", "note"],
|
|
65
|
+
properties: {
|
|
66
|
+
id: { enum: ids },
|
|
67
|
+
pass: { type: "boolean" },
|
|
68
|
+
evidence: { type: "string", description: "a span copied verbatim from the draft" },
|
|
69
|
+
note: { type: "string", description: "why; required when pass is false" },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
would_take_next_step: { type: "boolean", description: "whether this reader would take inputs.goal.next_if_worked now" },
|
|
74
|
+
evidence: { type: "string", description: "a span copied verbatim from the draft that decided would_take_next_step" },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
key: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Every problem with the verdict, as findings (see src/judge.mjs for the helpers in t). An empty
|
|
82
|
+
// list means the verdict is valid.
|
|
83
|
+
export function validate(verdict, pkt, t) {
|
|
84
|
+
if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { conditions, would_take_next_step, evidence }.")];
|
|
85
|
+
const out = [];
|
|
86
|
+
const expected = pkt.inputs.conditions.map((c) => c.id);
|
|
87
|
+
if (!Array.isArray(verdict.conditions)) {
|
|
88
|
+
out.push(t.shape("conditions is missing or not a list", "Give conditions: one { id, pass, evidence, note } per condition id."));
|
|
89
|
+
} else {
|
|
90
|
+
const seen = new Map();
|
|
91
|
+
verdict.conditions.forEach((c, i) => {
|
|
92
|
+
const at = `conditions[${i}]`;
|
|
93
|
+
if (!isObject(c)) { out.push(t.shape(`${at} is not an object`, "Each condition is { id, pass, evidence, note }.")); return; }
|
|
94
|
+
if (typeof c.id !== "string") out.push(t.shape(`${at}.id is not a string`, "Give the condition's id, as the packet lists it."));
|
|
95
|
+
else seen.set(c.id, (seen.get(c.id) ?? 0) + 1);
|
|
96
|
+
if (typeof c.pass !== "boolean") out.push(t.shape(`${at}.pass is not true or false`, "Set pass to the JSON boolean true or false."));
|
|
97
|
+
if (typeof c.note !== "string") out.push(t.shape(`${at}.note is not a string`, "Give a note, a string; it may be empty when the condition passes."));
|
|
98
|
+
else if (c.pass === false && !c.note.trim()) out.push(t.shape(`${at}.note is empty on a failing condition`, "Say why the condition fails."));
|
|
99
|
+
out.push(...t.evidence(c.evidence, `${at}.evidence`));
|
|
100
|
+
});
|
|
101
|
+
for (const id of expected) if (!seen.has(id)) out.push(t.finding("judge-doctor-condition-missing", `condition ${id} has no entry in conditions`, "Grade every condition the packet lists, each exactly once."));
|
|
102
|
+
for (const [id, n] of seen) {
|
|
103
|
+
if (!expected.includes(id)) out.push(t.finding("judge-doctor-condition-unknown", `conditions names ${id}, which is not a condition in the packet`, `Grade only the packet's conditions: ${expected.join(", ")}.`));
|
|
104
|
+
else if (n > 1) out.push(t.finding("judge-doctor-condition-duplicate", `condition ${id} appears ${n} times in conditions`, "Grade each condition exactly once."));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
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."));
|
|
108
|
+
out.push(...t.evidence(verdict.evidence, "evidence"));
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The station's status and findings from a valid verdict: passes when every condition passes and
|
|
113
|
+
// the reader would take the next step now.
|
|
114
|
+
export function derive(verdict, pkt, t) {
|
|
115
|
+
const findings = [];
|
|
116
|
+
const byId = new Map(verdict.conditions.map((c) => [c.id, c]));
|
|
117
|
+
for (const { id } of pkt.inputs.conditions) {
|
|
118
|
+
const c = byId.get(id);
|
|
119
|
+
if (c.pass) continue;
|
|
120
|
+
findings.push(t.finding("judge-doctor-condition", `condition ${id} fails: ${c.note.trim()}`, `Revise the draft until ${id} holds, then prepare and judge it again.`, t.lineOf(c.evidence)));
|
|
121
|
+
}
|
|
122
|
+
if (!verdict.would_take_next_step) {
|
|
123
|
+
findings.push(t.finding("judge-doctor-next-step", `the reader would not take the next step now (${pkt.inputs.goal.next_if_worked})`, "Revise the draft so the passage the judge quoted moves the reader to the next step.", t.lineOf(verdict.evidence)));
|
|
124
|
+
}
|
|
125
|
+
return { status: findings.length ? "fail" : "pass", findings };
|
|
126
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The judge registry: every judgment station `hyperspec judge` knows about, in run order. Each
|
|
2
|
+
// entry carries name, instructions (fixed text for the packet), skipReason(spec, draft) (null when
|
|
3
|
+
// the station applies; the draft is there for a station that needs something in it, like lineup),
|
|
4
|
+
// packet(spec, draft) returning { rubric, inputs, verdict_schema, key }, validate(verdict,
|
|
5
|
+
// packet, t, key) and derive(verdict, packet, t, key) (derive returns { status, findings } and may
|
|
6
|
+
// add summary, a one-line result such as attribution's accuracy); a station whose inputs come from
|
|
7
|
+
// files other than the spec and the draft also carries inputSources(spec), naming them, or null
|
|
8
|
+
// when this spec has none (lineup: the goldens; persona: the claims ledger), so record can call a
|
|
9
|
+
// packet whose inputs went out of date stale rather than edited; such a station also carries
|
|
10
|
+
// sourceSkip(spec, draft), the skip reason when it skips because of those files (else null), so a
|
|
11
|
+
// packet whose station stopped applying for that reason is stale too, and any other skip is not. The packet and key a station receives are
|
|
12
|
+
// always the ones record rebuilt from the spec and draft on disk, never read from a file; see
|
|
13
|
+
// src/judge.mjs for the framework that calls them. Adding a judge is one file plus one line here.
|
|
14
|
+
|
|
15
|
+
import * as doctor from "./doctor.mjs";
|
|
16
|
+
import * as lineup from "./lineup.mjs";
|
|
17
|
+
import * as reader from "./reader.mjs";
|
|
18
|
+
import * as persona from "./persona.mjs";
|
|
19
|
+
import * as attribution from "./attribution.mjs";
|
|
20
|
+
import * as knowledge from "./knowledge.mjs";
|
|
21
|
+
|
|
22
|
+
export const JUDGES = Object.freeze([
|
|
23
|
+
{ name: doctor.name, instructions: doctor.DOCTOR_INSTRUCTIONS, skipReason: doctor.skipReason, packet: doctor.packet, validate: doctor.validate, derive: doctor.derive },
|
|
24
|
+
{ name: lineup.name, instructions: lineup.LINEUP_INSTRUCTIONS, skipReason: lineup.skipReason, packet: lineup.packet, validate: lineup.validate, derive: lineup.derive, inputSources: lineup.inputSources, sourceSkip: lineup.sourceSkip },
|
|
25
|
+
{ name: reader.name, instructions: reader.READER_INSTRUCTIONS, skipReason: reader.skipReason, packet: reader.packet, validate: reader.validate, derive: reader.derive },
|
|
26
|
+
{ name: persona.name, instructions: persona.PERSONA_INSTRUCTIONS, skipReason: persona.skipReason, packet: persona.packet, validate: persona.validate, derive: persona.derive, inputSources: persona.inputSources, sourceSkip: persona.sourceSkip },
|
|
27
|
+
{ name: attribution.name, instructions: attribution.ATTRIBUTION_INSTRUCTIONS, skipReason: attribution.skipReason, packet: attribution.packet, validate: attribution.validate, derive: attribution.derive },
|
|
28
|
+
{ name: knowledge.name, instructions: knowledge.KNOWLEDGE_INSTRUCTIONS, skipReason: knowledge.skipReason, packet: knowledge.packet, validate: knowledge.validate, derive: knowledge.derive },
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const JUDGE_NAMES = Object.freeze(JUDGES.map((j) => j.name));
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Knowledge (fiction only): a judge reads the draft against each character's knowledge timeline (what
|
|
2
|
+
// they know, and by which point of the story) and reports every place a character shows they know
|
|
3
|
+
// something before they could: a leak. hyperspec writes the packet, checks every quoted span
|
|
4
|
+
// against the draft (src/judge.mjs's evidence rule) and derives the status: pass when there is no
|
|
5
|
+
// leak.
|
|
6
|
+
|
|
7
|
+
import { str } from "../placeholder.mjs";
|
|
8
|
+
|
|
9
|
+
export const name = "knowledge";
|
|
10
|
+
|
|
11
|
+
export const KNOWLEDGE_INSTRUCTIONS = [
|
|
12
|
+
"Read inputs.draft in order, against each character's knowledge timeline in inputs.characters: each entry says what the character knows (knows) by a point in the story (by).",
|
|
13
|
+
"Report every leak, a place where a character says, thinks or acts on something before the point their timeline gives them it, as an entry in leaks: the character's id; the passage as evidence, copied verbatim from inputs.draft, at least three whole words; and knows_too_early, what they know too early.",
|
|
14
|
+
"If no character knows anything too early, leaks is an empty list.",
|
|
15
|
+
"Answer only in the verdict shape given in verdict_schema.",
|
|
16
|
+
].join(" ");
|
|
17
|
+
|
|
18
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
19
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
|
|
20
|
+
|
|
21
|
+
// The characters that carry a knowledge timeline, each with its complete { by, knows } entries.
|
|
22
|
+
function timelines(spec) {
|
|
23
|
+
const list = Array.isArray(spec.data?.writing?.characters) ? spec.data.writing.characters : [];
|
|
24
|
+
return list.filter((c) => isObject(c) && str(c.id)).map((c) => ({
|
|
25
|
+
raw: c,
|
|
26
|
+
id: str(c.id),
|
|
27
|
+
name: str(c.name),
|
|
28
|
+
knowledge: (Array.isArray(c.knowledge) ? c.knowledge : []).filter((k) => isObject(k) && str(k.by) && str(k.knows)).map((k) => ({ by: str(k.by), knows: str(k.knows) })),
|
|
29
|
+
})).filter((c) => c.knowledge.length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The rubric: the distinct check.rubric texts of the characters with a timeline, in order, one per line.
|
|
33
|
+
function rubricOf(chars) {
|
|
34
|
+
return [...new Set(chars.map((c) => str(c.raw.check?.rubric)).filter(Boolean))].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// null when knowledge applies: fiction, at least one character with a knowledge timeline, and a
|
|
38
|
+
// rubric on one of them.
|
|
39
|
+
export function skipReason(spec) {
|
|
40
|
+
if (str(spec.data?.fiction) !== "true") return "the spec is not fiction; knowledge applies only with fiction: true";
|
|
41
|
+
const chars = timelines(spec);
|
|
42
|
+
if (!chars.length) return "no character in writing.characters has a knowledge timeline";
|
|
43
|
+
if (!rubricOf(chars)) return "no character's check has a rubric";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The packet's rubric, inputs (each timeline and the draft) and verdict schema. No answer key.
|
|
48
|
+
export function packet(spec, draft) {
|
|
49
|
+
const chars = timelines(spec);
|
|
50
|
+
return {
|
|
51
|
+
rubric: rubricOf(chars),
|
|
52
|
+
inputs: {
|
|
53
|
+
characters: chars.map((c) => ({ id: c.id, ...(c.name ? { name: c.name } : {}), knowledge: c.knowledge })),
|
|
54
|
+
draft: draft.text,
|
|
55
|
+
},
|
|
56
|
+
verdict_schema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
required: ["leaks"],
|
|
59
|
+
properties: {
|
|
60
|
+
leaks: {
|
|
61
|
+
type: "array",
|
|
62
|
+
description: "every place a character knows something too early; empty when there is none",
|
|
63
|
+
items: {
|
|
64
|
+
type: "object",
|
|
65
|
+
required: ["character", "evidence", "knows_too_early"],
|
|
66
|
+
properties: {
|
|
67
|
+
character: { enum: chars.map((c) => c.id) },
|
|
68
|
+
evidence: { type: "string", description: "the passage, copied verbatim from the draft, at least three words" },
|
|
69
|
+
knows_too_early: { type: "string", description: "what the character knows before their timeline gives it to them" },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
key: null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A character as the verdict wrote it, resolved to an id: an id or a name, trimmed, any case.
|
|
80
|
+
function characterId(value, characters) {
|
|
81
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
82
|
+
const v = value.trim().toLowerCase();
|
|
83
|
+
const c = characters.find((ch) => ch.id.toLowerCase() === v || (ch.name && ch.name.toLowerCase() === v));
|
|
84
|
+
return c ? c.id : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Every problem with the verdict, as findings; every evidence span must be in the draft.
|
|
88
|
+
export function validate(verdict, pkt, t) {
|
|
89
|
+
if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { leaks }.")];
|
|
90
|
+
if (!Array.isArray(verdict.leaks)) return [t.shape("leaks is missing or not a list", "Give leaks: one { character, evidence, knows_too_early } per leak, or [] when there is none.")];
|
|
91
|
+
const known = pkt.inputs.characters.map((c) => c.id);
|
|
92
|
+
const out = [];
|
|
93
|
+
verdict.leaks.forEach((l, i) => {
|
|
94
|
+
const at = `leaks[${i}]`;
|
|
95
|
+
if (!isObject(l)) { out.push(t.shape(`${at} is not an object`, "Write each leak as { character, evidence, knows_too_early }.")); return; }
|
|
96
|
+
if (typeof l.character !== "string") out.push(t.shape(`${at}.character is not a string`, "Give the character's id."));
|
|
97
|
+
else if (!characterId(l.character, pkt.inputs.characters)) out.push(t.finding("judge-knowledge-character-unknown", `${at}.character "${l.character}" is not a character with a knowledge timeline in the packet`, `Name one of: ${known.join(", ")}.`));
|
|
98
|
+
if (!nonEmpty(l.knows_too_early)) out.push(t.shape(`${at}.knows_too_early is missing or empty`, "Say what the character knows too early."));
|
|
99
|
+
out.push(...t.evidence(l.evidence, `${at}.evidence`));
|
|
100
|
+
});
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Passes when there is no leak; each leak is a failure at its passage's line.
|
|
105
|
+
export function derive(verdict, pkt, t) {
|
|
106
|
+
const findings = verdict.leaks.map((l) => {
|
|
107
|
+
const id = characterId(l.character, pkt.inputs.characters);
|
|
108
|
+
return t.finding("judge-knowledge-leak", `${id} knows too early: ${l.knows_too_early.trim()}`, `Cut or move what ${id} could not know yet, to the point their knowledge timeline gives it to them.`, t.lineOf(l.evidence));
|
|
109
|
+
});
|
|
110
|
+
return { status: findings.length ? "fail" : "pass", findings };
|
|
111
|
+
}
|