bareloop 0.40.0 → 0.42.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 +96 -1
- package/README.md +13 -21
- package/bareloop.context.md +141 -128
- package/package.json +5 -4
- package/src/authorflow.js +346 -38
- package/src/authoring.js +92 -7
- package/src/authorjob.js +82 -20
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +39 -30
- package/src/authorscout.js +112 -4
- package/src/bundle.js +7 -4
- package/src/bundlerun.js +4 -1
- package/src/cli.js +1 -1
- package/src/config.js +129 -29
- package/src/declaredclose.js +23 -12
- package/src/draftspend.js +4 -4
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/index.js +2 -4
- package/src/inputlook.js +301 -0
- package/src/interviewrun.js +17 -25
- package/src/job.js +15 -8
- package/src/jobblock.js +7 -2
- package/src/judged.js +11 -43
- package/src/keysfile.js +15 -7
- package/src/kinds.js +86 -5
- package/src/labelscan.js +170 -0
- package/src/modellist.js +84 -0
- package/src/monthly.js +23 -43
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +34 -16
- package/src/panel/authorsession.js +161 -80
- package/src/panel/index.html +553 -152
- package/src/panel/jobplan.js +34 -11
- package/src/panel/runroutes.js +3 -0
- package/src/panel/server.js +54 -18
- package/src/panel/settingsroutes.js +232 -173
- package/src/planrun.js +5 -4
- package/src/providerrows.js +226 -141
- package/src/providers.js +58 -332
- package/src/providershapes.js +18 -0
- package/src/replay.js +1 -1
- package/src/rubricstep.js +804 -0
- package/src/source.js +166 -1
- package/src/thinking.js +130 -0
- package/src/u-readout.js +1 -1
- package/src/userrun.js +64 -75
- package/types/authorflow.d.ts +272 -18
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +9 -7
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +72 -2
- package/types/config.d.ts +23 -6
- package/types/declaredclose.d.ts +4 -2
- package/types/draftspend.d.ts +8 -8
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/index.d.ts +1 -1
- package/types/inputlook.d.ts +238 -0
- package/types/job.d.ts +5 -5
- package/types/jobblock.d.ts +8 -1
- package/types/judged.d.ts +5 -36
- package/types/keysfile.d.ts +15 -4
- package/types/labelscan.d.ts +38 -0
- package/types/modellist.d.ts +50 -0
- package/types/monthly.d.ts +4 -3
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorsession.d.ts +20 -11
- package/types/panel/jobplan.d.ts +12 -4
- package/types/panel/server.d.ts +18 -6
- package/types/providerrows.d.ts +192 -99
- package/types/providers.d.ts +34 -159
- package/types/providershapes.d.ts +10 -0
- package/types/rubricstep.d.ts +286 -0
- package/types/source.d.ts +94 -0
- package/types/thinking.d.ts +90 -0
- package/types/userrun.d.ts +17 -1
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { createRubric, checkStep, renderGaps, numbersInQuote } from 'bareguard';
|
|
5
|
+
import { factPartOf, factShapeReds, factDecides, factClaims, factRows, plainFactItem, factBrief } from './facts.js';
|
|
6
|
+
|
|
7
|
+
// PART 1a (docs/product/ITEM33-BUILD.md, "Addendum 2026-10-12"): the rubric job on LOCAL FILES — what one job line leaves
|
|
8
|
+
// behind and how it is told to the worker.
|
|
9
|
+
//
|
|
10
|
+
// One output per job line: `output/steps/<n>.json`, a JSON object
|
|
11
|
+
//
|
|
12
|
+
// { "text": "<the answer for line n>",
|
|
13
|
+
// "cites": { "<input label>": [ { "claim": "<one thing the text relies on>", "quote": "<words copied from that input>" } ] } }
|
|
14
|
+
//
|
|
15
|
+
// The `rubric-step` close kind (src/kinds.js) grades it with bareguard's rubric primitive; the worker's brief (the goal
|
|
16
|
+
// this module writes) and the composer's prompt (src/authorflow.js) both quote the SAME contract text from here, so the
|
|
17
|
+
// file the worker is told to write and the file the close reads cannot drift.
|
|
18
|
+
|
|
19
|
+
/** where the per-line step files live, inside the job's tree; `copyOut` delivers them with the rest of `output/` */
|
|
20
|
+
export const STEP_DIR = 'output/steps';
|
|
21
|
+
/** a step file's tree-relative path @param {number} n the job line number @returns {string} */
|
|
22
|
+
export const stepFileRel = (n) => `${STEP_DIR}/${n}.json`;
|
|
23
|
+
/** the largest step file the close reads: a bigger one is a stop, never silently truncated (bareguard's own source cap) */
|
|
24
|
+
export const STEP_MAX_BYTES = 5 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The one statement of the step-file shape, in plain words. The worker's goal and the composer's prompt both carry it.
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function stepFileContract() {
|
|
31
|
+
return [
|
|
32
|
+
`For each numbered job line N, write ONE file ${STEP_DIR}/N.json: a JSON object with exactly these keys.`,
|
|
33
|
+
' "text" — your answer for that line, as a string.',
|
|
34
|
+
' "cites" — an object: for each input you drew on, its label as the key and a list of {"claim", "quote"} as the value.',
|
|
35
|
+
' "claim" is one thing your text relies on; "quote" is words copied EXACTLY from that input (spacing and',
|
|
36
|
+
' ** or __ marks do not matter). Every number in a claim must also appear in its quote.',
|
|
37
|
+
' In each claim, copy every number, time and price exactly as the input writes it (for example "9:27 AM", not "09:27"),',
|
|
38
|
+
' because the check matches the numbers in your claim against your quote.',
|
|
39
|
+
'A job line the brief below marks as a FACTS line is different: its file holds ONLY {"results": [{"quote": "...", "values":',
|
|
40
|
+
'{"<fact>": "<value>"}}]} — one result per thing found, one quote copied exactly from the text of that one result, the values written as the quote',
|
|
41
|
+
'writes them; no "text" and no "cites". A line the brief marks as a TEXT line keeps "text" (free words), and every number in it',
|
|
42
|
+
'must be copied from a quote of an earlier facts line.',
|
|
43
|
+
'Write nothing outside output/, and never edit the files under input/. The file must be complete, valid JSON: a file cut',
|
|
44
|
+
'off before its closing brace counts as not written.',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the worker is told about each line, from the close that will grade it (PART 1b item 7): a FACTS line gets its
|
|
50
|
+
* facts and its count; a TEXT line that follows a facts line is told its numbers come from that line's quotes. Written by code from
|
|
51
|
+
* the signed parts, never by a model. A line with no rubric-step stage gets nothing here.
|
|
52
|
+
* @param {any} closeDecl @returns {{line: number, text: string}[]}
|
|
53
|
+
*/
|
|
54
|
+
export function lineBriefs(closeDecl) {
|
|
55
|
+
const stages = isRec(closeDecl) && Array.isArray(closeDecl.stages) ? closeDecl.stages : [];
|
|
56
|
+
/** @type {{line: number, text: string}[]} */
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const st of stages) {
|
|
59
|
+
if (!(isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts) && Number.isInteger(st.params.step))) continue;
|
|
60
|
+
const n = st.params.step;
|
|
61
|
+
const fp = factPartOf(st.params);
|
|
62
|
+
if (fp) {
|
|
63
|
+
const c = isRec(fp.count) ? fp.count : null;
|
|
64
|
+
const count = !c ? 'one result for every one you find' : c.min !== undefined && c.max !== undefined
|
|
65
|
+
? (c.min === c.max ? `exactly ${c.min} result${c.min === 1 ? '' : 's'}` : `${c.min} to ${c.max} results`)
|
|
66
|
+
: c.min !== undefined ? `at least ${c.min} results` : `at most ${c.max} results`;
|
|
67
|
+
out.push({
|
|
68
|
+
line: n,
|
|
69
|
+
text: [
|
|
70
|
+
`Job line ${n} is a FACTS line. Write ${stepFileRel(n)} as {"results": [{"quote": "...", "values": {...}}]} and nothing else — no "text", no "cites".`,
|
|
71
|
+
` One result per thing you find in the input "${fp.source}": ${count}.`,
|
|
72
|
+
' "quote": ONE quote per result, copied exactly from that result\'s own text in the input (at most 4000 characters) — never joined from two results.',
|
|
73
|
+
' "values": one entry per fact below, written exactly as the quote writes it (for example "9:27 AM", not "09:27"; "389", not "389.00").',
|
|
74
|
+
...fp.facts.map((/** @type {any} */ f) => ` - ${factBrief(f)}`),
|
|
75
|
+
].join('\n'),
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const tie = st.params.parts.find((/** @type {any} */ x) => isRec(x) && x.class === 'countable' && x.check === 'numbersFromFacts');
|
|
80
|
+
if (tie) {
|
|
81
|
+
out.push({
|
|
82
|
+
line: n,
|
|
83
|
+
text: `Job line ${n} is a TEXT line. Write ${stepFileRel(n)} as {"text": "..."}. Your words are free, but every number in the text must be copied exactly as it is written in a quote you wrote for job line ${tie.from.join(', ')} — use no other number.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The worker's goal for a rubric job on local files — written by code from the person's own numbered lines, never by a
|
|
92
|
+
* model. It states everything the close will judge about the FORM of the answer (the file, the keys, the quotes); what
|
|
93
|
+
* each line asks for stays in the person's own words, with its `~` rules beneath it.
|
|
94
|
+
* @param {{jobLines: {n: number, text: string, rule: string}[], files: {label: string, rel: string}[], confirmedGoal?: string|null, closeDecl?: any}} o
|
|
95
|
+
* `confirmedGoal` — the one goal sentence the person confirmed in the confirm turn, kept as the plan line
|
|
96
|
+
* `closeDecl` — the drafted close: with it, each line is briefed by what its stage checks (a facts line or a text line, `lineBriefs`)
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function rubricGoal({ jobLines, files, confirmedGoal = null, closeDecl = null }) {
|
|
100
|
+
const inputs = files.map((f) => ` - ${f.label}: ${f.rel}`).join('\n');
|
|
101
|
+
const lines = jobLines.map((l) => `${l.n}. ${l.text}${l.rule ? `\n rule: ${l.rule}` : ''}`).join('\n');
|
|
102
|
+
return [
|
|
103
|
+
'Do the job below from the input files, then write one JSON file per numbered line.',
|
|
104
|
+
`The input files (label: file):\n${inputs}`,
|
|
105
|
+
stepFileContract(),
|
|
106
|
+
`The job, line by line:\n${lines}`,
|
|
107
|
+
...(closeDecl ? lineBriefs(closeDecl).map((b) => b.text) : []),
|
|
108
|
+
...(confirmedGoal ? [`The plan the person confirmed: ${confirmedGoal}`] : []),
|
|
109
|
+
].join('\n\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── the `rubric-step` stage: its parameters, and the rubric they build ───────────────────────────────────────────────
|
|
113
|
+
//
|
|
114
|
+
// A stage serves exactly ONE job line N and reads exactly one file, output/steps/N.json. Its parameters are
|
|
115
|
+
//
|
|
116
|
+
// step N
|
|
117
|
+
// parts [ { text, class, ... } ] — the person's constraints on that line, each SORTED by the drafter
|
|
118
|
+
//
|
|
119
|
+
// A part's class is one of `countable` (a limit a machine counts, or a quote a claim must show), `judged` (needs the
|
|
120
|
+
// judge — stored, never run in this build), `guard` (a rule about web sources — needs a web input, none exist yet) or
|
|
121
|
+
// `none` (the drafter could not sort it). Only `countable` parts become checks; the others are REDS at drafting
|
|
122
|
+
// (src/authoring.js `checkRubricParts`) so no signed stage ever carries one.
|
|
123
|
+
|
|
124
|
+
/** the closed set of classes a part is sorted into; `none` = "I could not sort this one" (a red, in plain words, never a guess) */
|
|
125
|
+
export const PART_CLASSES = Object.freeze(['countable', 'fact', 'judged', 'guard', 'none']);
|
|
126
|
+
/**
|
|
127
|
+
* The counted/quoted checks a `countable` part may name, with the fields each needs — read off bareguard's own rule table
|
|
128
|
+
* (`rubricVocabulary`): the names are bareguard's, so a check this build cannot run is inexpressible, not rejected late.
|
|
129
|
+
* (`max`/`min` need a numeric field the step file does not have, so they are not offered.)
|
|
130
|
+
* @type {Readonly<Record<string, readonly string[]>>}
|
|
131
|
+
*/
|
|
132
|
+
export const COUNT_CHECKS = Object.freeze({
|
|
133
|
+
maxWords: Object.freeze(['value']),
|
|
134
|
+
minWords: Object.freeze(['value']),
|
|
135
|
+
maxLines: Object.freeze(['value']),
|
|
136
|
+
sections: Object.freeze(['names']),
|
|
137
|
+
sectionWords: Object.freeze(['names', 'wordsPerSection']),
|
|
138
|
+
cited: Object.freeze(['source']),
|
|
139
|
+
// a COUNT of the claims listed under an input: the code counts the list (flattenStep), bareguard's own min/max compares it
|
|
140
|
+
minClaims: Object.freeze(['value', 'source']),
|
|
141
|
+
maxClaims: Object.freeze(['value', 'source']),
|
|
142
|
+
// PART 1b: put on every prose step by CODE (`withFactBindings`) when an earlier line is a fact step — every number in the text
|
|
143
|
+
// must be in a checked quote of those lines. The model never writes it; bareguard's `numbersInQuote` reads it (gradeStep).
|
|
144
|
+
numbersFromFacts: Object.freeze(['from']),
|
|
145
|
+
});
|
|
146
|
+
/** every field a part may carry, whatever its class */
|
|
147
|
+
const PART_FIELDS = Object.freeze(['text', 'class', 'check', 'value', 'names', 'wordsPerSection', 'source', 'domains', 'covers', 'rules', 'from', 'record', 'facts', 'count', 'tested', 'sample']);
|
|
148
|
+
|
|
149
|
+
/** @typedef {{code: string, path: string, detail: string}} ShapeRed */
|
|
150
|
+
|
|
151
|
+
/** @param {unknown} v @returns {v is Record<string, any>} */
|
|
152
|
+
const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
153
|
+
/** @param {unknown} v @returns {v is string} */
|
|
154
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
|
|
155
|
+
/** @param {unknown} v @returns {v is string[]} */
|
|
156
|
+
const nameList = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => nonEmpty(x) && x.length <= 1000);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The SHAPE of a `rubric-step` stage's parameters — what a part may carry — and nothing about which class is allowed in
|
|
160
|
+
* this build (that is `checkRubricParts`, which also sees the whole job). Pure, never throws.
|
|
161
|
+
* @param {unknown} p the stage's `params`
|
|
162
|
+
* @param {string} at the path prefix for reds, e.g. `stages[1].params`
|
|
163
|
+
* @returns {ShapeRed[]}
|
|
164
|
+
*/
|
|
165
|
+
export function rubricStepShapeReds(p, at) {
|
|
166
|
+
/** @type {ShapeRed[]} */
|
|
167
|
+
const reds = [];
|
|
168
|
+
const red = (/** @type {string} */ code, /** @type {string} */ path, /** @type {string} */ detail) => reds.push({ code, path, detail });
|
|
169
|
+
if (!isRec(p)) { red('invalid-value', at, 'an object {step, parts}'); return reds; }
|
|
170
|
+
if (!(Number.isInteger(p.step) && p.step >= 1)) red('invalid-value', `${at}.step`, 'the number of the job line this stage serves (an integer, 1 or more)');
|
|
171
|
+
if (!Array.isArray(p.parts)) { red('invalid-value', `${at}.parts`, 'an array of {text, class, ...} — one entry per constraint the person put on this line (may be empty)'); return reds; }
|
|
172
|
+
p.parts.forEach((/** @type {any} */ part, /** @type {number} */ i) => {
|
|
173
|
+
const w = `${at}.parts[${i}]`;
|
|
174
|
+
if (!isRec(part)) { red('invalid-value', w, '{text, class, ...}'); return; }
|
|
175
|
+
for (const k of Object.keys(part)) {
|
|
176
|
+
if (!PART_FIELDS.includes(k)) red('invalid-value', `${w}.${k}`, `"${k}" is not a field of a part — fields: ${PART_FIELDS.join(', ')}`);
|
|
177
|
+
}
|
|
178
|
+
if (!nonEmpty(part.text)) red('missing-field', `${w}.text`, 'the part, in the person\'s own words (a non-empty string)');
|
|
179
|
+
if (!PART_CLASSES.includes(part.class)) { red('invalid-value', `${w}.class`, `one of ${PART_CLASSES.join(' | ')}`); return; }
|
|
180
|
+
if (Object.hasOwn(part, 'covers') && !(Array.isArray(part.covers) && part.covers.length > 0 && new Set(part.covers).size === part.covers.length
|
|
181
|
+
&& part.covers.every((/** @type {unknown} */ x) => Number.isInteger(x) && /** @type {number} */ (x) >= 1))) {
|
|
182
|
+
red('invalid-value', `${w}.covers`, 'the numbers of the confirmed checks this part carries out (distinct integers, 1 or more)');
|
|
183
|
+
}
|
|
184
|
+
if (Object.hasOwn(part, 'rules') && !(Array.isArray(part.rules) && part.rules.length > 0 && new Set(part.rules).size === part.rules.length
|
|
185
|
+
&& part.rules.every((/** @type {unknown} */ x) => Number.isInteger(x) && /** @type {number} */ (x) >= 1))) {
|
|
186
|
+
red('invalid-value', `${w}.rules`, 'the numbers of the line\'s ~ rules this part carries out (distinct integers, 1 or more)');
|
|
187
|
+
}
|
|
188
|
+
if (part.class === 'fact') { factShapeReds(part, w, (c, path, d) => red(c, path, d)); return; }
|
|
189
|
+
if (part.class === 'guard') {
|
|
190
|
+
if (!nameList(part.domains)) red('invalid-value', `${w}.domains`, 'a non-empty array of web domains the rule is about');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (part.class !== 'countable') return;
|
|
194
|
+
if (!Object.hasOwn(COUNT_CHECKS, part.check)) { red('invalid-value', `${w}.check`, `one of ${Object.keys(COUNT_CHECKS).join(' | ')}`); return; }
|
|
195
|
+
for (const f of COUNT_CHECKS[part.check]) {
|
|
196
|
+
const ok = f === 'value' || f === 'wordsPerSection' ? Number.isInteger(part[f]) && part[f] >= 1
|
|
197
|
+
: f === 'names' ? nameList(part.names)
|
|
198
|
+
: f === 'from' ? Array.isArray(part.from) && part.from.length > 0 && new Set(part.from).size === part.from.length && part.from.every((/** @type {unknown} */ x) => Number.isInteger(x) && /** @type {number} */ (x) >= 1)
|
|
199
|
+
: nonEmpty(part.source) && part.source.length <= 128;
|
|
200
|
+
if (!ok) {
|
|
201
|
+
red('invalid-value', `${w}.${f}`, f === 'names' ? 'a non-empty array of section names'
|
|
202
|
+
: f === 'source' ? 'the label of the input a quote must come from'
|
|
203
|
+
: f === 'from' ? 'the numbers of the earlier fact lines whose checked quotes the text\'s numbers come from (distinct integers, 1 or more)'
|
|
204
|
+
: 'an integer, 1 or more');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return reds;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** the labels of the inputs a step's `cited` parts name (each once, in order) @param {any} params @returns {string[]} */
|
|
212
|
+
export function citedLabels(params) {
|
|
213
|
+
const parts = Array.isArray(params?.parts) ? params.parts : [];
|
|
214
|
+
return [...new Set(parts.filter((/** @type {any} */ x) => isRec(x) && ((x.class === 'countable' && x.check === 'cited') || x.class === 'fact')).map((/** @type {any} */ x) => String(x.source)))];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The rubric spec for ONE step, built from its parameters and the sha256 of each input it cites (taken from the source
|
|
219
|
+
* manifest at run start — a rubric is rebuilt per run because the inputs belong to the run, not to the signed job).
|
|
220
|
+
* `happened` is bareguard's own foundational check; the `text` floor below it stops a step file whose only key is junk
|
|
221
|
+
* from passing a rubric that has no countable part.
|
|
222
|
+
* @param {{step: number, parts: any[]}} params
|
|
223
|
+
* @param {Record<string, string>} inputShas label -> sha256 hex, for every label the step cites
|
|
224
|
+
* @returns {Record<string, any>} a spec for bareguard's `createRubric`
|
|
225
|
+
*/
|
|
226
|
+
export function buildStepSpec(params, inputShas) {
|
|
227
|
+
/** @type {Record<string, any>[]} */
|
|
228
|
+
const checks = factPartOf(params) ? [] : [{ id: 'text-present', rule: 'nonEmpty', field: 'text' }];
|
|
229
|
+
params.parts.forEach((part, i) => {
|
|
230
|
+
if (part.class === 'fact') {
|
|
231
|
+
// ONE bareguard `cited` check per fact part: the quote is in the frozen input, every value is in the quote and meets its rule
|
|
232
|
+
checks.push({ id: `p${i + 1}-facts`, rule: 'cited', claims: `cites:${part.source}`, source: part.source, decides: factDecides(part) });
|
|
233
|
+
if (part.count?.min !== undefined) checks.push({ id: `p${i + 1}-count-min`, rule: 'min', field: `claims:${part.source}`, value: part.count.min });
|
|
234
|
+
if (part.count?.max !== undefined) checks.push({ id: `p${i + 1}-count-max`, rule: 'max', field: `claims:${part.source}`, value: part.count.max });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (part.class !== 'countable') return;
|
|
238
|
+
const id = `p${i + 1}-${part.check}`;
|
|
239
|
+
switch (part.check) {
|
|
240
|
+
case 'maxWords': case 'minWords': case 'maxLines': checks.push({ id, rule: part.check, field: 'text', value: part.value }); break;
|
|
241
|
+
case 'sections': checks.push({ id, rule: 'sections', field: 'text', names: [...part.names] }); break;
|
|
242
|
+
case 'sectionWords': checks.push({ id, rule: 'sectionWords', field: 'text', names: [...part.names], wordsPerSection: part.wordsPerSection }); break;
|
|
243
|
+
case 'cited': checks.push({ id, rule: 'cited', claims: `cites:${part.source}`, source: part.source }); break;
|
|
244
|
+
case 'minClaims': checks.push({ id, rule: 'min', field: `claims:${part.source}`, value: part.value }); break;
|
|
245
|
+
case 'maxClaims': checks.push({ id, rule: 'max', field: `claims:${part.source}`, value: part.value }); break;
|
|
246
|
+
case 'numbersFromFacts': break; // read in gradeStep from the earlier lines' step files (bareguard's numbersInQuote), not a rubric rule
|
|
247
|
+
default: throw new Error(`rubric-step: no check named "${part.check}"`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
schema: 1,
|
|
252
|
+
goal: `job line ${params.step}`,
|
|
253
|
+
inputs: Object.entries(inputShas).map(([name, sha256]) => ({ name, sha256 })),
|
|
254
|
+
checkpoints: { [String(params.step)]: { gating: false, checks } },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** the sha256 of text, hex @param {string|Buffer} b @returns {string} */
|
|
259
|
+
export const sha256Of = (b) => createHash('sha256').update(b).digest('hex');
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The step file's bytes as the close reads them: the parsed JSON object, or WHY there is none. A file cut off mid-write
|
|
263
|
+
* parses to nothing (reason `not-json`); text cut off INSIDE an otherwise valid JSON object cannot be told from a short
|
|
264
|
+
* answer — that limit is documented, not hidden.
|
|
265
|
+
* @param {string|null} bytes the file's text, or null when it does not exist
|
|
266
|
+
* @returns {{obj: Record<string, any>, why: null}|{obj: null, why: 'missing'|'not-json'|'not-object'}}
|
|
267
|
+
*/
|
|
268
|
+
export function parseStepFile(bytes) {
|
|
269
|
+
if (bytes === null) return { obj: null, why: 'missing' };
|
|
270
|
+
let v;
|
|
271
|
+
try { v = JSON.parse(bytes); } catch { return { obj: null, why: 'not-json' }; }
|
|
272
|
+
if (!isRec(v)) return { obj: null, why: 'not-object' };
|
|
273
|
+
return { obj: v, why: null };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The step file as bareguard's rubric reads it: a flat object. `text` stays `text`; each label in `cites` becomes the field
|
|
278
|
+
* `cites:<label>` (the name a `cited` check reads its claims from). Nothing else in the file is read.
|
|
279
|
+
* @param {Record<string, any>} obj
|
|
280
|
+
* @param {string[]} [countLabels] labels whose claim COUNT a minClaims/maxClaims part reads
|
|
281
|
+
* @param {any} [factPart] the step's `fact` part: its `results` become claims (text built by code) under `cites:<source>`
|
|
282
|
+
* @returns {Record<string, any>}
|
|
283
|
+
*/
|
|
284
|
+
export function flattenStep(obj, countLabels = [], factPart = null) {
|
|
285
|
+
/** @type {Record<string, any>} */
|
|
286
|
+
const fields = {};
|
|
287
|
+
// the number of claims listed under each label a claim-count check names (0 when the label is absent): counted HERE, by
|
|
288
|
+
// code, from the file's own list — the worker never writes a number the check trusts
|
|
289
|
+
for (const label of countLabels) {
|
|
290
|
+
const list = isRec(obj.cites) && Object.hasOwn(obj.cites, label) ? obj.cites[label] : null;
|
|
291
|
+
fields[`claims:${label}`] = Array.isArray(list) ? list.length : 0;
|
|
292
|
+
}
|
|
293
|
+
if (factPart) {
|
|
294
|
+
const claims = factClaims(factPart, obj.results);
|
|
295
|
+
fields[`claims:${factPart.source}`] = claims.length;
|
|
296
|
+
fields[`cites:${factPart.source}`] = claims;
|
|
297
|
+
}
|
|
298
|
+
// a missing or non-string `text` is read as empty, so the one red a worker sees is "text is missing or empty"
|
|
299
|
+
fields.text = typeof obj.text === 'string' ? obj.text : '';
|
|
300
|
+
if (isRec(obj.cites) && !factPart) for (const [label, list] of Object.entries(obj.cites)) fields[`cites:${label}`] = list;
|
|
301
|
+
return fields;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* One bareguard gap as a sentence the worker can act on. The numbers and names are bareguard's own measurements and the
|
|
306
|
+
* person's signed limits, never anything this module infers; a gap this table does not know falls back to bareguard's own
|
|
307
|
+
* rendering, so nothing is ever dropped.
|
|
308
|
+
* @param {number} step @param {string} why why there was no object, when there was none
|
|
309
|
+
* @param {any} g a bareguard gap
|
|
310
|
+
* @param {any} [params] the stage's parameters (a fact step words its gaps by fact)
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
export function plainStepGap(step, why, g, params = null) {
|
|
314
|
+
const file = stepFileRel(step);
|
|
315
|
+
const at = `line ${step}`;
|
|
316
|
+
const fact = params ? factPartOf(params) : null;
|
|
317
|
+
if (fact && g.check === 'cited') {
|
|
318
|
+
if (g.kind === 'missing' || g.kind === 'no-claims') return `${at}: ${file} has no results — add one result per thing you found: {"quote": ..., "values": {...}}`;
|
|
319
|
+
if (g.kind === 'unsupported') return (g.items ?? []).map((/** @type {string} */ it) => `${at}: ${plainFactItem(it, fact)}`).join('\n');
|
|
320
|
+
if (g.kind === 'too-many') return `${at}: the results hold too many different quotes to check (${g.measured} against a limit of ${g.limit})`;
|
|
321
|
+
return `${at}: the results cannot be checked (${g.kind})`;
|
|
322
|
+
}
|
|
323
|
+
if (fact && (g.check === 'min' || g.check === 'max') && String(g.field ?? '').startsWith('claims:')) {
|
|
324
|
+
return g.check === 'min' ? `${at}: ${g.measured ?? 0} result(s) are listed, at least ${g.limit} are required` : `${at}: ${g.measured} results are listed, at most ${g.limit} are allowed`;
|
|
325
|
+
}
|
|
326
|
+
if (g.check === 'happened' || g.id === 'happened') {
|
|
327
|
+
const reason = why === 'missing' ? 'does not exist' : why === 'not-json' ? 'is not valid JSON (was it cut off?)' : why === 'not-object' ? 'is not a JSON object' : 'is empty';
|
|
328
|
+
return `${at}: ${file} ${reason}`;
|
|
329
|
+
}
|
|
330
|
+
if (g.check === 'nonEmpty') return `${at}: "text" is missing or empty in ${file}`;
|
|
331
|
+
if (g.check === 'maxWords') return `${at}: "text" has ${g.measured} words, at most ${g.limit} are allowed`;
|
|
332
|
+
if (g.check === 'minWords') return `${at}: "text" has ${g.measured} words, at least ${g.limit} are required`;
|
|
333
|
+
if (g.check === 'maxLines') return `${at}: "text" has ${g.measured} lines, at most ${g.limit} are allowed`;
|
|
334
|
+
if (g.check === 'sections') return `${at}: "text" is missing section(s): ${(g.items ?? []).join(', ')}`;
|
|
335
|
+
if ((g.check === 'min' || g.check === 'max') && String(g.field ?? '').startsWith('claims:')) {
|
|
336
|
+
const label = String(g.field).slice('claims:'.length);
|
|
337
|
+
return g.check === 'min'
|
|
338
|
+
? `${at}: only ${g.measured ?? 0} claim(s) are listed under "${label}" in "cites", at least ${g.limit} are required`
|
|
339
|
+
: `${at}: ${g.measured} claims are listed under "${label}" in "cites", at most ${g.limit} are allowed`;
|
|
340
|
+
}
|
|
341
|
+
if (g.check === 'cited') {
|
|
342
|
+
const label = String(g.field ?? '').replace(/^cites:/, '');
|
|
343
|
+
if (g.kind === 'missing') return `${at}: "cites" has nothing under "${label}" — list each claim with a quote copied from that input`;
|
|
344
|
+
if (g.kind === 'no-claims') return `${at}: "cites.${label}" is empty — list each claim with a quote copied from that input`;
|
|
345
|
+
if (g.kind === 'unsupported') return `${at}: ${g.measured} of ${g.limit} quotes under "${label}" are not found word for word in that input, or carry a number the quote lacks: ${(g.items ?? []).join(', ')}`;
|
|
346
|
+
return `${at}: "cites.${label}" cannot be checked (${g.kind})`;
|
|
347
|
+
}
|
|
348
|
+
return `${at}: ${renderGaps([g])}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Grade ONE step: build the rubric, run it over the step file's fields, and say what happened in the close's own terms.
|
|
353
|
+
* Pure of the filesystem (the runner in src/kinds.js reads the files). `stopped` = the instrument failed (never a red).
|
|
354
|
+
* @param {{params: {step: number, parts: any[]}, bytes: string|null, outputBytes: string|null, inputs: Record<string, string>,
|
|
355
|
+
* factQuotes?: Record<number, string[]>, factMissing?: number[]}} o
|
|
356
|
+
* `factQuotes` maps an earlier fact line's number to the quotes its (already checked) step file holds — what a prose step's numbers are tied to
|
|
357
|
+
* `factMissing` lists the earlier fact lines whose step file is not there (yet): a normal red, never an instrument fault
|
|
358
|
+
* `inputs` maps each cited label to its frozen TEXT; its sha256 is taken here from that text, and bareguard compares it to
|
|
359
|
+
* the manifest's recorded sha via `inputShas`.
|
|
360
|
+
* @param {Record<string, string>} inputShas the manifest's recorded sha256 per cited label
|
|
361
|
+
* @returns {Promise<{verdict: 'green'|'red'|'stopped', gapLines: string[], fault: string|null, detail: Record<string, any>}>}
|
|
362
|
+
*/
|
|
363
|
+
export async function gradeStep({ params, bytes, outputBytes, inputs, factQuotes = {}, factMissing = [] }, inputShas) {
|
|
364
|
+
let rubric;
|
|
365
|
+
try { rubric = createRubric(buildStepSpec(params, inputShas)); } catch (e) {
|
|
366
|
+
return { verdict: 'stopped', gapLines: [], fault: `the rubric for job line ${params.step} could not be built: ${/** @type {Error} */ (e).message}`, detail: {} };
|
|
367
|
+
}
|
|
368
|
+
const parsed = parseStepFile(bytes);
|
|
369
|
+
const countLabels = [...new Set(params.parts.filter((/** @type {any} */ x) => x.class === 'countable' && (x.check === 'minClaims' || x.check === 'maxClaims')).map((/** @type {any} */ x) => String(x.source)))];
|
|
370
|
+
const r = await checkStep(rubric, String(params.step), parsed.obj === null ? null : flattenStep(parsed.obj, countLabels, factPartOf(params)), {
|
|
371
|
+
inputs, ...(outputBytes === null ? {} : { outputBytes }),
|
|
372
|
+
});
|
|
373
|
+
// PART 1b: a prose step's numbers are tied to the checked facts of earlier lines (bareguard's `numbersInQuote`: token match on the
|
|
374
|
+
// numbers, not a canonical compare — a documented limit). Words are free; a number that no checked quote holds is a red.
|
|
375
|
+
/** @type {string[]} */
|
|
376
|
+
const extra = [];
|
|
377
|
+
let extraCount = 0;
|
|
378
|
+
const tie = params.parts.find((/** @type {any} */ x) => isRec(x) && x.class === 'countable' && x.check === 'numbersFromFacts');
|
|
379
|
+
if (tie) {
|
|
380
|
+
for (const n of factMissing) {
|
|
381
|
+
extraCount += 1;
|
|
382
|
+
extra.push(`line ${n}'s answer is not there yet (${stepFileRel(n)}), so the numbers in line ${params.step}'s text cannot be tied to it`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (tie && parsed.obj !== null && typeof parsed.obj.text === 'string') {
|
|
386
|
+
const quotes = tie.from.flatMap((/** @type {number} */ n) => factQuotes[n] ?? []);
|
|
387
|
+
const t = numbersInQuote(parsed.obj.text, quotes.join('\n'));
|
|
388
|
+
if (!t.ok) {
|
|
389
|
+
extraCount = t.missing.length || 1;
|
|
390
|
+
extra.push(`line ${params.step}: "text" has number(s) ${t.missing.join(', ')} that no checked quote of line ${tie.from.join(', ')} holds — use only numbers from those quotes, written as they write them`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// the stage's ONE trend number for the fix-loop governor (F192 e, mirrored): failing CLAIMS under a `cited` check (bareguard's
|
|
394
|
+
// own `measured` count of unsupported quotes), plus 1 for every other failing check — lower is better, compared to the
|
|
395
|
+
// best so far through the same `closeGrade` {stage, value} path numeric stages use
|
|
396
|
+
const redCount = extraCount + r.full.gaps.reduce((/** @type {number} */ n, /** @type {any} */ g) => n + (g.check === 'cited' && g.kind === 'unsupported' && typeof g.measured === 'number' ? g.measured : 1), 0);
|
|
397
|
+
const detail = { step: params.step, rubricSha: r.rubricSha, outputSha: r.outputSha, why: parsed.why, gaps: r.full.gaps.length, ...(r.verdict === 'red' || extra.length > 0 ? { redCount } : {}) };
|
|
398
|
+
if (r.verdict === 'stopped') return { verdict: 'stopped', gapLines: [], fault: r.fault ? `${r.fault.kind}: ${r.fault.detail}` : 'the rubric stopped', detail };
|
|
399
|
+
if (r.verdict === 'green' && extra.length === 0) return { verdict: 'green', gapLines: [], fault: null, detail };
|
|
400
|
+
return { verdict: 'red', gapLines: [...extra, ...r.gaps.flatMap((g) => plainStepGap(params.step, parsed.why ?? 'empty', g, params).split('\n'))], fault: null, detail };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── R11: the sorting rules, applied at DRAFTING ──────────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The labels of a job's numbered inputs, or null when it carries none (a caller that has no inputs in hand skips the label rule).
|
|
407
|
+
* @param {unknown} inputs @returns {string[]|null}
|
|
408
|
+
*/
|
|
409
|
+
export function inputLabelsOf(inputs) {
|
|
410
|
+
if (!Array.isArray(inputs)) return null;
|
|
411
|
+
const labels = inputs.map((i) => (isRec(i) ? i.label : null)).filter((l) => typeof l === 'string');
|
|
412
|
+
return labels.length > 0 ? labels : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @param {string} text @returns {string} a person's own words, one line, bounded for a message */
|
|
416
|
+
const sayText = (text) => {
|
|
417
|
+
const one = String(text).replace(/\s+/g, ' ').trim();
|
|
418
|
+
return one.length > 80 ? `${one.slice(0, 80)}…` : one;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The word range a part allows, as `[min, max]` (null = no bound on that side), read off its check. `sectionWords` allows
|
|
423
|
+
* each of its sections 80-120% of its asked words (bareguard's own band), so the whole text must hold at least
|
|
424
|
+
* `names x ceil(0.8 x asked)` words — and at most `names x floor(1.2 x asked)` for the sections alone.
|
|
425
|
+
* @param {any} part @returns {{min: number|null, max: number|null, say: string}|null}
|
|
426
|
+
*/
|
|
427
|
+
function wordRange(part) {
|
|
428
|
+
if (part.check === 'maxWords') return { min: null, max: part.value, say: `at most ${part.value} words` };
|
|
429
|
+
if (part.check === 'minWords') return { min: part.value, max: null, say: `at least ${part.value} words` };
|
|
430
|
+
if (part.check === 'sectionWords') {
|
|
431
|
+
const lo = Math.ceil(part.wordsPerSection * 0.8);
|
|
432
|
+
const hi = Math.floor(part.wordsPerSection * 1.2);
|
|
433
|
+
return { min: part.names.length * lo, max: null, say: `${part.names.length} section(s) of ${lo}-${hi} words each, so at least ${part.names.length * lo} words` };
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* R11 — the drafting-time rules for the `rubric-step` stages of a declaration, each a red in the person's plain words that
|
|
440
|
+
* names the job line and ends "Nothing spent." where nothing was. Pure; never throws. Judged parts are RED in this build
|
|
441
|
+
* (the judge arrives in the next one): the sorting is still asked for, so a part that needs the judge is named as such
|
|
442
|
+
* instead of being approximated by a count.
|
|
443
|
+
* - a stage serves exactly the one job line it names (`step` = its only `fromLine`), and no line has two stages;
|
|
444
|
+
* - a part with no class (`none`) cannot be counted or checked against a quote;
|
|
445
|
+
* - a `judged` part needs the judge; a `guard` part is about web sources and the job has no web input;
|
|
446
|
+
* - two countable parts of ONE line that no text can satisfy together (a minimum above a maximum) are a contradiction,
|
|
447
|
+
* naming both parts and both numbers;
|
|
448
|
+
* - a `cited` part names a label the person's inputs do not have.
|
|
449
|
+
* @param {any[]} stages the declaration's stages
|
|
450
|
+
* @param {{inputLabels?: string[]|null, red: (code: string, path: string, detail: string, extra?: object) => void}} o
|
|
451
|
+
* `inputLabels` absent = the label rule is not run (a caller with no inputs in hand)
|
|
452
|
+
*/
|
|
453
|
+
export function checkRubricParts(stages, { inputLabels = null, red }) {
|
|
454
|
+
/** @type {Map<number, string>} */
|
|
455
|
+
const owner = new Map();
|
|
456
|
+
/** the stage index of each fact line (PART 1b: a prose step ties its numbers to fact lines that are checked BEFORE it) @type {Map<number, number>} */
|
|
457
|
+
const factAt = new Map();
|
|
458
|
+
stages.forEach((st, i) => {
|
|
459
|
+
if (isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Number.isInteger(st.params.step) && factPartOf(st.params)) factAt.set(st.params.step, i);
|
|
460
|
+
});
|
|
461
|
+
stages.forEach((st, i) => {
|
|
462
|
+
if (!isRec(st) || st.kind !== 'rubric-step' || !isRec(st.params)) return;
|
|
463
|
+
const at = `stages[${i}]`;
|
|
464
|
+
const p = st.params;
|
|
465
|
+
const step = p.step;
|
|
466
|
+
const label = nonEmpty(st.name) ? st.name : at;
|
|
467
|
+
const line = Number.isInteger(step) ? step : '?';
|
|
468
|
+
if (Array.isArray(st.fromLine) && !(st.fromLine.length === 1 && st.fromLine[0] === step)) {
|
|
469
|
+
red('rubric-step-line', `${at}.fromLine`, `line ${line}: stage "${label}" reads the file for job line ${line} but says it serves line(s) ${st.fromLine.join(', ')}. One rubric-step stage serves exactly its own line.`, { stage: label });
|
|
470
|
+
}
|
|
471
|
+
if (Number.isInteger(step)) {
|
|
472
|
+
const twin = owner.get(step);
|
|
473
|
+
if (twin !== undefined) red('rubric-step-twice', `${at}.params.step`, `line ${line}: stages "${twin}" and "${label}" both read the file for job line ${line}. Give each job line one stage.`, { stage: label, twin });
|
|
474
|
+
else owner.set(step, label);
|
|
475
|
+
}
|
|
476
|
+
if (!Array.isArray(p.parts)) return;
|
|
477
|
+
/** @type {{part: any, range: {min: number|null, max: number|null, say: string}}[]} */
|
|
478
|
+
const ranges = [];
|
|
479
|
+
const factLine = p.parts.some((/** @type {any} */ x) => isRec(x) && x.class === 'fact');
|
|
480
|
+
if (!factLine) {
|
|
481
|
+
const wantFrom = [...factAt].filter(([, idx]) => idx < i).map(([n]) => n);
|
|
482
|
+
const tied = p.parts.find((/** @type {any} */ x) => isRec(x) && x.class === 'countable' && x.check === 'numbersFromFacts');
|
|
483
|
+
if (wantFrom.length > 0 && !(tied && Array.isArray(tied.from) && wantFrom.every((n) => tied.from.includes(n)))) {
|
|
484
|
+
red('rubric-numbers-unbound', `${at}.params.parts`, `line ${line}: this text step comes after the fact line(s) ${wantFrom.join(', ')}, so every number in its text must be tied to their checked quotes. Nothing spent.`, { line: step, stage: label });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
p.parts.forEach((/** @type {any} */ part, /** @type {number} */ j) => {
|
|
488
|
+
if (!isRec(part)) return;
|
|
489
|
+
const w = `${at}.params.parts[${j}]`;
|
|
490
|
+
const said = sayText(part.text ?? '');
|
|
491
|
+
if (part.class === 'none') {
|
|
492
|
+
red('rubric-part-unsortable', w, `line ${line}: '${said}' can't be counted or checked against a quote. Say it as a count (words, sections, rows) or as something a quote must show.` + (factLine ? ' This line lists facts: a limit you can say as a time, number, date or money fact goes INSIDE its fact part (add the fact, list the rule\'s number in that part\'s "rules"); anything else goes in "refused", never a part of its own.' : ''), { line: step, stage: label });
|
|
493
|
+
} else if (part.class === 'judged') {
|
|
494
|
+
red('rubric-part-judged', w, `line ${line}: this rule needs the judge, which arrives in the next build. Nothing spent.` + (factLine ? ' This line lists facts: a limit you can say as a time, number, date or money fact goes INSIDE its fact part (add the fact, list the rule\'s number in that part\'s "rules"); anything else goes in "refused", never a part of its own.' : ''), { line: step, stage: label });
|
|
495
|
+
} else if (part.class === 'guard') {
|
|
496
|
+
red('rubric-part-guard', w, `line ${line}: '${said}' is about web sources, and this job has no web input. Nothing spent.`, { line: step, stage: label });
|
|
497
|
+
} else if (part.class === 'fact') {
|
|
498
|
+
if (Array.isArray(inputLabels) && !inputLabels.includes(part.source)) {
|
|
499
|
+
red('rubric-part-source', `${w}.source`, `line ${line}: '${said}' asks for quotes from "${sayText(part.source ?? '')}", but the job's inputs are ${inputLabels.map((l) => `"${l}"`).join(', ')}.`, { line: step, stage: label });
|
|
500
|
+
}
|
|
501
|
+
if (p.parts.filter((/** @type {any} */ x) => isRec(x) && x.class === 'fact').length > 1) {
|
|
502
|
+
red('rubric-fact-twice', w, `line ${line}: '${said}' is a second fact list on the same line. One line has one list of facts; put them all in one. Nothing spent.`, { line: step, stage: label });
|
|
503
|
+
}
|
|
504
|
+
} else if (part.class === 'countable') {
|
|
505
|
+
if (part.check === 'numbersFromFacts') {
|
|
506
|
+
const bad = Array.isArray(part.from) ? part.from.filter((/** @type {number} */ n) => !(factAt.has(n) && /** @type {number} */ (factAt.get(n)) < i)) : [];
|
|
507
|
+
if (factLine || bad.length > 0) {
|
|
508
|
+
red('rubric-numbers-source', `${w}.from`, `line ${line}: the numbers in the text are tied to line(s) ${bad.length ? bad.join(', ') : part.from}, which are not fact lines checked before this one. Nothing spent.`, { line: step, stage: label });
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (factLine) {
|
|
513
|
+
red('rubric-fact-text', w, `line ${line}: '${said}' counts or quotes free text, but this line lists facts and has no text. Say it as a fact (a shape and a rule) or remove it. Nothing spent.`, { line: step, stage: label });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (part.check === 'cited' && Array.isArray(inputLabels) && !inputLabels.includes(part.source)) {
|
|
517
|
+
red('rubric-part-source', `${w}.source`, `line ${line}: '${said}' asks for a quote from "${sayText(part.source ?? '')}", but the job's inputs are ${inputLabels.map((l) => `"${l}"`).join(', ')}.`, { line: step, stage: label });
|
|
518
|
+
}
|
|
519
|
+
const range = wordRange(part);
|
|
520
|
+
if (range) ranges.push({ part, range });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
if (factLine && rubricStepShapeReds(p, at).length === 0) {
|
|
524
|
+
// bareguard is the judge of whether a fact list is SIGNABLE (anchors, units, bounds): ask it at drafting, at $0, so a list
|
|
525
|
+
// it would refuse at run time is a red the drafter can fix now
|
|
526
|
+
try { createRubric(buildStepSpec(/** @type {{step: number, parts: any[]}} */ (p), Object.fromEntries(citedLabels(p).map((l) => [l, '0'.repeat(64)])))); } catch (e) {
|
|
527
|
+
red('rubric-fact-invalid', `${at}.params.parts`, `line ${line}: the facts cannot be signed: ${/** @type {Error} */ (e).message}`, { line: step, stage: label });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (let a = 0; a < ranges.length; a += 1) {
|
|
531
|
+
for (let b = a + 1; b < ranges.length; b += 1) {
|
|
532
|
+
const [x, y] = [ranges[a], ranges[b]];
|
|
533
|
+
const lo = Math.max(x.range.min ?? -Infinity, y.range.min ?? -Infinity);
|
|
534
|
+
const hi = Math.min(x.range.max ?? Infinity, y.range.max ?? Infinity);
|
|
535
|
+
if (lo > hi) {
|
|
536
|
+
red('rubric-contradiction', `${at}.params.parts`, `line ${line}: '${sayText(x.part.text)}' (${x.range.say}) and '${sayText(y.part.text)}' (${y.range.say}) cannot both be met — no text has between ${lo} and ${hi} words. Change one of them. Nothing spent.`, { line: step, stage: label });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** the ~ rules of one job line, one string each (`rules` when the line has the array, else the joined `rule` split on "; ") @param {any} l @returns {string[]} */
|
|
544
|
+
export function lineRules(l) {
|
|
545
|
+
if (isRec(l) && Array.isArray(l.rules)) return l.rules.filter((/** @type {unknown} */ r) => typeof r === 'string' && r);
|
|
546
|
+
return isRec(l) && typeof l.rule === 'string' && l.rule ? l.rule.split('; ').filter(Boolean) : [];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** @param {unknown} b @returns {string[]} the spellings a rule bound can have in the person's own words */
|
|
550
|
+
const boundSpellings = (b) => {
|
|
551
|
+
const s = String(b);
|
|
552
|
+
return [s, s.replace(/^0+(?=\d)/, ''), s.replace(/\.0+$/, '')];
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* PART 1b, RF16 + RF17 — the rules that need the person's own job lines. Only a close built for REQUIRED FACTS is held to them (a
|
|
557
|
+
* close with a fact part, or any part that says which `~` rules it carries out): an older Part 1a close is left as it was signed.
|
|
558
|
+
* - every `~` rule of every job line is carried out by a part (its number in the part's `rules`), or is listed in `refused` with
|
|
559
|
+
* its `rule` number, or its whole line is refused: a rule that is none of those was silently dropped, and that is a red;
|
|
560
|
+
* - a number the AI made out of the person's words (a bound the person did not write) must be marked `aiProposal`, and a time
|
|
561
|
+
* limit the AI proposes must be a range (`between`), never one-sided — "not a red-eye" must not let a 10 PM flight through.
|
|
562
|
+
* @param {any[]} stages @param {{jobLines?: any[]|null, refused?: any, red: (code: string, path: string, detail: string, extra?: object) => void}} o
|
|
563
|
+
*/
|
|
564
|
+
export function checkRubricRules(stages, { jobLines = null, refused = null, red }) {
|
|
565
|
+
const rubric = stages.map((st, i) => ({ st, i })).filter(({ st }) => isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts));
|
|
566
|
+
const anyTag = rubric.some(({ st }) => st.params.parts.some((/** @type {any} */ x) => isRec(x) && (x.class === 'fact' || Object.hasOwn(x, 'rules'))));
|
|
567
|
+
if (!anyTag || !Array.isArray(jobLines)) return;
|
|
568
|
+
const refusedList = Array.isArray(refused) ? refused.filter(isRec) : [];
|
|
569
|
+
for (const l of jobLines) {
|
|
570
|
+
if (!isRec(l) || !Number.isInteger(l.n)) continue;
|
|
571
|
+
const rules = lineRules(l);
|
|
572
|
+
const wholeLine = refusedList.some((r) => r.line === l.n && !Object.hasOwn(r, 'rule'));
|
|
573
|
+
const carried = new Set(rubric.filter(({ st }) => st.params.step === l.n).flatMap(({ st }) => st.params.parts.flatMap((/** @type {any} */ x) => (isRec(x) && Array.isArray(x.rules) ? x.rules : []))));
|
|
574
|
+
rules.forEach((text, k) => {
|
|
575
|
+
const n = k + 1;
|
|
576
|
+
if (wholeLine || carried.has(n) || refusedList.some((r) => r.line === l.n && r.rule === n)) return;
|
|
577
|
+
red('rubric-rule-dropped', `jobLines[${l.n - 1}].rules[${k}]`, `line ${l.n}: your rule "${sayText(text)}" is neither checked nor listed under Not checked. Make it a check (list ${n} in the part's "rules") or list it in "refused" as {"line": ${l.n}, "rule": ${n}, "reason": "..."}. Nothing spent.`, { line: l.n, rule: n });
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
for (const { st, i } of rubric) {
|
|
581
|
+
const fp = factPartOf(st.params);
|
|
582
|
+
if (!fp || !Array.isArray(fp.facts)) continue;
|
|
583
|
+
const jl = jobLines.find((l) => isRec(l) && l.n === st.params.step);
|
|
584
|
+
const said = jl ? `${jl.text ?? ''} ${lineRules(jl).join(' ')}` : null;
|
|
585
|
+
fp.facts.forEach((/** @type {any} */ f, /** @type {number} */ j) => {
|
|
586
|
+
if (!isRec(f) || !isRec(f.rule)) return;
|
|
587
|
+
const w = `stages[${i}].params.parts[${st.params.parts.indexOf(fp)}].facts[${j}]`;
|
|
588
|
+
const bounds = f.rule.op === 'between' ? [f.rule.lo, f.rule.hi] : [f.rule.value];
|
|
589
|
+
if (said !== null && !f.aiProposal && bounds.some((b) => !boundSpellings(b).some((sp) => said.includes(sp)))) {
|
|
590
|
+
red('rubric-fact-unmarked', w, `line ${st.params.step}: the limit on fact "${f.name}" (${bounds.join(' to ')}) is not in the words the person wrote, so it is the AI's own number. Set "aiProposal": true on that fact so the card says "AI's proposal — check it". Nothing spent.`, { line: st.params.step });
|
|
591
|
+
}
|
|
592
|
+
if (f.aiProposal && f.shape === 'time' && f.rule.op !== 'between') {
|
|
593
|
+
red('rubric-fact-onesided', w, `line ${st.params.step}: fact "${f.name}" is a time limit the AI made up, and a one-sided limit lets the other end through (a 10 PM flight passes "not a red-eye"). Use a range: "op": "between" with "lo" and "hi". Nothing spent.`, { line: st.params.step });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** a clock time as a person writes it: 7:00, 07:00, 7:00 AM, 11 PM is not matched (no minutes) */
|
|
600
|
+
const CLOCK = '\\d{1,2}:\\d{2}(?:\\s?[AaPp][Mm])?';
|
|
601
|
+
const TIME_RANGE_RE = new RegExp(`\\b(?:between|from)\\s+(${CLOCK})\\s+(?:and|to|-)\\s+(${CLOCK})`, 'i');
|
|
602
|
+
const NEGATING_RE = /\b(?:not|no|never|outside|except|before|after|without)\b/i;
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* PART 1c L3 - the person's own words win over the AI's reading of them, settled by CODE before anything is validated or signed:
|
|
606
|
+
* - a limit on a fact whose bounds are all in the words the person wrote (the line, its `~` rules, an answer they gave) is the
|
|
607
|
+
* person's, so it is NOT marked "AI's proposal" (smv710w8c3wdh: the plan called the person's own range the AI's);
|
|
608
|
+
* - a time range the AI proposed while the person wrote ONE explicit positive time range ("between 07:00 and 23:00") on that line
|
|
609
|
+
* is replaced by exactly that range (smv710w8c3wdh: the plan said 5:00 AM). Only when it is unambiguous: one such range in the
|
|
610
|
+
* line's rules and one AI-proposed `between` time fact on the line; a rule with a negation ("not between ...") is never read as a range;
|
|
611
|
+
* - a `~` rule is a check or under Not checked, never both: a `refused` entry for a rule that a part of its own line carries is dropped.
|
|
612
|
+
* Pure; returns a new declaration, the input untouched. The model is told the same in the brief; this makes it true either way.
|
|
613
|
+
* @param {any} declaration @param {any[]|null|undefined} jobLines @returns {any}
|
|
614
|
+
*/
|
|
615
|
+
export function settlePersonsWords(declaration, jobLines) {
|
|
616
|
+
if (!isRec(declaration) || !Array.isArray(declaration.stages) || !Array.isArray(jobLines)) return declaration;
|
|
617
|
+
const stages = declaration.stages.map((/** @type {any} */ st) => {
|
|
618
|
+
if (!(isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts))) return st;
|
|
619
|
+
const fp = factPartOf(st.params);
|
|
620
|
+
const jl = jobLines.find((l) => isRec(l) && l.n === st.params.step);
|
|
621
|
+
if (!fp || !Array.isArray(fp.facts) || !jl) return st;
|
|
622
|
+
const rules = lineRules(jl);
|
|
623
|
+
const said = `${jl.text ?? ''} ${rules.join(' ')}`;
|
|
624
|
+
const ranges = rules.filter((r) => !NEGATING_RE.test(r)).map((r) => TIME_RANGE_RE.exec(r)).filter((m) => m !== null);
|
|
625
|
+
const proposed = fp.facts.filter((/** @type {any} */ f) => isRec(f) && f.aiProposal === true && f.shape === 'time' && isRec(f.rule) && f.rule.op === 'between');
|
|
626
|
+
const facts = fp.facts.map((/** @type {any} */ f) => {
|
|
627
|
+
if (!isRec(f) || !isRec(f.rule) || f.aiProposal !== true) return f;
|
|
628
|
+
const own = (/** @type {unknown[]} */ bounds) => bounds.every((b) => boundSpellings(b).some((sp) => said.includes(sp)));
|
|
629
|
+
const bounds = f.rule.op === 'between' ? [f.rule.lo, f.rule.hi] : [f.rule.value];
|
|
630
|
+
const { aiProposal: _drop, ...rest } = f;
|
|
631
|
+
if (own(bounds)) return rest;
|
|
632
|
+
if (ranges.length === 1 && proposed.length === 1 && proposed[0] === f) {
|
|
633
|
+
const m = /** @type {RegExpExecArray} */ (ranges[0]);
|
|
634
|
+
return { ...rest, rule: { op: 'between', lo: m[1], hi: m[2] } };
|
|
635
|
+
}
|
|
636
|
+
return f;
|
|
637
|
+
});
|
|
638
|
+
const parts = st.params.parts.map((/** @type {any} */ x) => (x === fp ? { ...x, facts } : x));
|
|
639
|
+
return { ...st, params: { ...st.params, parts } };
|
|
640
|
+
});
|
|
641
|
+
const carried = (/** @type {number} */ line, /** @type {number} */ n) => stages.some((/** @type {any} */ st) => isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && st.params.step === line
|
|
642
|
+
&& Array.isArray(st.params.parts) && st.params.parts.some((/** @type {any} */ x) => isRec(x) && Array.isArray(x.rules) && x.rules.includes(n)));
|
|
643
|
+
/** @type {any} */
|
|
644
|
+
const out = { ...declaration, stages };
|
|
645
|
+
if (Array.isArray(declaration.refused)) out.refused = declaration.refused.filter((/** @type {any} */ r) => !(isRec(r) && Number.isInteger(r.line) && Number.isInteger(r.rule) && carried(r.line, r.rule)));
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* PART 1b — put the number tie on every prose step. A rubric-step stage with no fact part that comes AFTER one or more fact
|
|
651
|
+
* stages gets exactly one `numbersFromFacts` part naming those lines; one the model wrote is replaced. Pure; returns a new
|
|
652
|
+
* declaration (the input is untouched). The model never writes this part: it is code's, so a draft cannot omit it.
|
|
653
|
+
* @param {any} declaration @returns {any}
|
|
654
|
+
*/
|
|
655
|
+
export function withFactBindings(declaration) {
|
|
656
|
+
if (!isRec(declaration) || !Array.isArray(declaration.stages)) return declaration;
|
|
657
|
+
/** @type {number[]} */
|
|
658
|
+
const seen = [];
|
|
659
|
+
const stages = declaration.stages.map((/** @type {any} */ st) => {
|
|
660
|
+
if (!(isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts))) return st;
|
|
661
|
+
if (factPartOf(st.params)) { if (Number.isInteger(st.params.step)) seen.push(st.params.step); return st; }
|
|
662
|
+
const rest = st.params.parts.filter((/** @type {any} */ x) => !(isRec(x) && x.class === 'countable' && x.check === 'numbersFromFacts'));
|
|
663
|
+
if (seen.length === 0) return rest.length === st.params.parts.length ? st : { ...st, params: { ...st.params, parts: rest } };
|
|
664
|
+
const from = [...seen];
|
|
665
|
+
return { ...st, params: { ...st.params, parts: [...rest, { text: `every number in the text is in a quote checked on line ${from.join(', ')}`, class: 'countable', check: 'numbersFromFacts', from }] } };
|
|
666
|
+
});
|
|
667
|
+
return { ...declaration, stages };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* The ~ rules the drafter listed under Not checked (`refused` entries that name a `rule`), each as one line in the person's own
|
|
672
|
+
* words: `line N: "<the rule as written>" — <the drafter's reason>` (PART 1b, RF6 / RF16).
|
|
673
|
+
* @param {any} closeDecl @param {any[]|null|undefined} jobLines @returns {string[]}
|
|
674
|
+
*/
|
|
675
|
+
export function ruleNotChecked(closeDecl, jobLines) {
|
|
676
|
+
const refused = isRec(closeDecl) && Array.isArray(closeDecl.refused) ? closeDecl.refused : [];
|
|
677
|
+
return refused.filter((/** @type {any} */ r) => isRec(r) && Number.isInteger(r.line) && Number.isInteger(r.rule) && typeof r.reason === 'string').map((/** @type {any} */ r) => {
|
|
678
|
+
const text = lineRules((jobLines ?? []).find((/** @type {any} */ l) => isRec(l) && l.n === r.line))[r.rule - 1];
|
|
679
|
+
return `line ${r.line}: ${text ? `"${text}"` : `rule ${r.rule}`} — ${r.reason}`;
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The Not-checked lines code owns for the rubric-step stages of a close: a prose step's CONTENT is judged by the person at
|
|
685
|
+
* Accept, and the card says so (PART 1b, RF5). One line per prose step (a stage with no fact part).
|
|
686
|
+
* @param {any} closeDecl @returns {string[]}
|
|
687
|
+
*/
|
|
688
|
+
export function proseNotChecked(closeDecl) {
|
|
689
|
+
const stages = isRec(closeDecl) && Array.isArray(closeDecl.stages) ? closeDecl.stages : [];
|
|
690
|
+
return stages.filter((/** @type {any} */ st) => isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts) && !factPartOf(st.params))
|
|
691
|
+
.map((/** @type {any} */ st) => `line ${st.params.step}: what the text says — you judge it at Accept`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── the sign-vs-run invariant: the checks the person is SHOWN are the checks the signed close RUNS ───────────────────
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* One countable part as a plain sentence, written by code from the part's signed fields (never from the model's words), so the
|
|
698
|
+
* plan card and the close are one thing seen twice.
|
|
699
|
+
* @param {any} part @returns {string}
|
|
700
|
+
*/
|
|
701
|
+
export function partSentence(part) {
|
|
702
|
+
const list = (/** @type {unknown} */ v) => (Array.isArray(v) ? v.map((x) => String(x)).join(', ') : '');
|
|
703
|
+
const n = (/** @type {unknown} */ v, /** @type {string} */ unit) => `${v} ${unit}${v === 1 ? '' : 's'}`;
|
|
704
|
+
switch (part.check) {
|
|
705
|
+
case 'maxWords': return `the text is at most ${n(part.value, 'word')}`;
|
|
706
|
+
case 'minWords': return `the text is at least ${n(part.value, 'word')}`;
|
|
707
|
+
case 'maxLines': return `the text is at most ${n(part.value, 'line')}`;
|
|
708
|
+
case 'sections': return `the text has the section(s): ${list(part.names)}`;
|
|
709
|
+
case 'sectionWords': return `each section (${list(part.names)}) is about ${part.wordsPerSection} words`;
|
|
710
|
+
case 'cited': return `every claim is backed by a quote copied from "${part.source}"`;
|
|
711
|
+
case 'minClaims': return `at least ${part.value} claim(s) are listed under "${part.source}"`;
|
|
712
|
+
case 'maxClaims': return `at most ${part.value} claim(s) are listed under "${part.source}"`;
|
|
713
|
+
case 'numbersFromFacts': return `every number in the text is in a quote checked on line ${list(part.from)}`;
|
|
714
|
+
default: return String(part.text ?? part.check);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The checks a signed close actually runs, per job line, in plain code-written sentences: one per countable part of every
|
|
720
|
+
* rubric-step stage. This is the ONE reader both the plan card and the sign-time invariant use.
|
|
721
|
+
* @param {any} closeDecl
|
|
722
|
+
* @returns {{line: number, text: string, covers: number[]}[]}
|
|
723
|
+
*/
|
|
724
|
+
export function checksOfClose(closeDecl) {
|
|
725
|
+
const stages = isRec(closeDecl) && Array.isArray(closeDecl.stages) ? closeDecl.stages : [];
|
|
726
|
+
return stages.flatMap((/** @type {any} */ st) => (isRec(st) && st.kind === 'rubric-step' && isRec(st.params) && Array.isArray(st.params.parts)
|
|
727
|
+
? st.params.parts.filter((/** @type {any} */ p) => isRec(p) && (p.class === 'countable' || p.class === 'fact')).flatMap((/** @type {any} */ p) => {
|
|
728
|
+
const covers = Array.isArray(p.covers) ? p.covers.filter(Number.isInteger) : [];
|
|
729
|
+
// a fact part is one plan row per fact (and one for its count): the person signs the rows, the close runs the same list
|
|
730
|
+
return (p.class === 'fact' ? factRows(p) : [partSentence(p)]).map((/** @type {string} */ text) => ({ line: st.params.step, text, covers }));
|
|
731
|
+
})
|
|
732
|
+
: []));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Reconcile the CONFIRMED plan with the close the composer actually wrote. The plan card after drafting shows exactly
|
|
737
|
+
* the close's own checks (`checksOfClose`); every confirmed check that no part carries out (no part lists its number in
|
|
738
|
+
* `covers`) is moved to Not checked, in the person's words, with the reason. A check can therefore never be shown and then
|
|
739
|
+
* silently dropped (run mv5bk400: "lists 3 releases" was confirmed, then left out of the signed close without a word).
|
|
740
|
+
* @param {{confirmed: {text: string, fromLine: number[]|null}[], notChecked: string[], closeDecl: any, jobLines?: any[]|null}} o
|
|
741
|
+
* `jobLines` — the person's numbered lines, so a ~ rule the drafter listed as Not checked reads in the person's own words
|
|
742
|
+
* @returns {{checkItems: {text: string, fromLine: number[]}[], notChecked: string[], moved: {text: string, fromLine: number[]|null}[]}}
|
|
743
|
+
*/
|
|
744
|
+
export function reconcileRubricPlan({ confirmed, notChecked, closeDecl, jobLines = null }) {
|
|
745
|
+
const real = checksOfClose(closeDecl);
|
|
746
|
+
const covered = new Set(real.flatMap((c) => c.covers));
|
|
747
|
+
const moved = confirmed.filter((_, i) => !covered.has(i + 1));
|
|
748
|
+
return {
|
|
749
|
+
checkItems: real.map((c) => ({ text: c.text, fromLine: [c.line] })),
|
|
750
|
+
notChecked: [
|
|
751
|
+
...notChecked,
|
|
752
|
+
...proseNotChecked(closeDecl),
|
|
753
|
+
...ruleNotChecked(closeDecl, jobLines),
|
|
754
|
+
...moved.map((m) => `${m.fromLine && m.fromLine.length ? `line ${m.fromLine.join(', ')}: ` : ''}${m.text} — nothing in this close counts it or checks it against a quote`),
|
|
755
|
+
],
|
|
756
|
+
moved,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The sign-time invariant: the checks the person was shown (`shown`, from the plan card) are exactly the checks the signed
|
|
762
|
+
* close runs (`checksOfClose`), per line. Pure.
|
|
763
|
+
* @param {{text: string, fromLine: number[]|null}[]} shown @param {any} closeDecl
|
|
764
|
+
* @returns {{ok: true}|{ok: false, error: string}}
|
|
765
|
+
*/
|
|
766
|
+
export function planMatchesClose(shown, closeDecl) {
|
|
767
|
+
const key = (/** @type {number|null|undefined} */ line, /** @type {string} */ text) => `line ${line ?? '?'}: ${text}`;
|
|
768
|
+
const ran = checksOfClose(closeDecl).map((c) => key(c.line, c.text));
|
|
769
|
+
const saw = shown.map((c) => key(Array.isArray(c.fromLine) ? c.fromLine[0] : null, c.text));
|
|
770
|
+
const onlyShown = saw.filter((k) => !ran.includes(k));
|
|
771
|
+
const onlyRan = ran.filter((k) => !saw.includes(k));
|
|
772
|
+
if (onlyShown.length === 0 && onlyRan.length === 0 && saw.length === ran.length) return { ok: true };
|
|
773
|
+
const parts = [];
|
|
774
|
+
if (onlyShown.length) parts.push(`shown to you but not in the signed close: ${onlyShown.join('; ')}`);
|
|
775
|
+
if (onlyRan.length) parts.push(`in the signed close but not shown to you: ${onlyRan.join('; ')}`);
|
|
776
|
+
return { ok: false, error: `The checks you were shown do not match the checks the signed close would run (${parts.join(' | ')}). Nothing was signed — draft again.` };
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The frozen TEXT of the named inputs, read the way the runner reads them: the source door's record (`source.json`, one level
|
|
781
|
+
* above the tree) names each input's file and sha256, and a file whose bytes no longer match is not returned. Used at DRAFTING
|
|
782
|
+
* (the drafter reads the input, the early tests run on it) and at run start (the backstop). Never throws.
|
|
783
|
+
* @param {string} workdir the frozen tree @param {string[]} labels
|
|
784
|
+
* @returns {Promise<{ok: true, texts: Record<string, string>}|{ok: false, why: string}>}
|
|
785
|
+
*/
|
|
786
|
+
export async function readFrozenInputs(workdir, labels) {
|
|
787
|
+
/** @type {any} */
|
|
788
|
+
let manifest;
|
|
789
|
+
try { manifest = JSON.parse(await readFile(join(dirname(workdir), 'source.json'), 'utf8')); } catch (e) {
|
|
790
|
+
return { ok: false, why: `the source record could not be read: ${/** @type {Error} */ (e).message}` };
|
|
791
|
+
}
|
|
792
|
+
const recorded = Array.isArray(manifest?.inputs) ? manifest.inputs : [];
|
|
793
|
+
/** @type {Record<string, string>} */
|
|
794
|
+
const texts = {};
|
|
795
|
+
for (const label of labels) {
|
|
796
|
+
const row = recorded.find((/** @type {any} */ r) => r && r.label === label);
|
|
797
|
+
if (!row || typeof row.rel !== 'string' || typeof row.sha256 !== 'string') return { ok: false, why: `the source record has no input labelled "${label}"` };
|
|
798
|
+
let buf;
|
|
799
|
+
try { buf = await readFile(join(workdir, row.rel)); } catch (e) { return { ok: false, why: `input "${label}" could not be read: ${/** @type {Error} */ (e).message}` }; }
|
|
800
|
+
if (sha256Of(buf) !== row.sha256) return { ok: false, why: `input "${label}" no longer matches the bytes frozen at the source door` };
|
|
801
|
+
texts[label] = buf.toString('utf8');
|
|
802
|
+
}
|
|
803
|
+
return { ok: true, texts };
|
|
804
|
+
}
|