bareloop 0.43.0 → 0.44.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bareloop.context.md +52 -13
  3. package/package.json +2 -2
  4. package/src/authorflow.js +26 -11
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +27 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +4 -1
  9. package/src/draftspend.js +20 -2
  10. package/src/facts.js +54 -9
  11. package/src/factwords.js +58 -6
  12. package/src/inputlook.js +38 -3
  13. package/src/job.js +1 -1
  14. package/src/kinds.js +5 -0
  15. package/src/pagedropwords.js +64 -0
  16. package/src/pagemap.js +264 -0
  17. package/src/pagerules.js +319 -0
  18. package/src/panel/authorroutes.js +34 -30
  19. package/src/panel/authorsession.js +29 -5
  20. package/src/panel/index.html +465 -65
  21. package/src/panel/jobplan.js +4 -2
  22. package/src/panel/runroutes.js +46 -22
  23. package/src/panel/server.js +791 -28
  24. package/src/panel/status.js +33 -0
  25. package/src/panelmoney.js +29 -0
  26. package/src/planrun.js +53 -4
  27. package/src/promptregisters.js +4 -0
  28. package/src/replay.js +118 -7
  29. package/src/rubricstep.js +123 -11
  30. package/src/runlist.js +2 -1
  31. package/src/schedulewords.js +6 -3
  32. package/src/source.js +18 -0
  33. package/src/thinking.js +63 -2
  34. package/src/userrun.js +28 -14
  35. package/types/authorflow.d.ts +42 -6
  36. package/types/authorjob.d.ts +10 -2
  37. package/types/declaredclose.d.ts +2 -0
  38. package/types/draftspend.d.ts +27 -1
  39. package/types/facts.d.ts +15 -0
  40. package/types/factwords.d.ts +26 -3
  41. package/types/inputlook.d.ts +15 -0
  42. package/types/kinds.d.ts +5 -0
  43. package/types/pagedropwords.d.ts +26 -0
  44. package/types/pagemap.d.ts +154 -0
  45. package/types/pagerules.d.ts +86 -0
  46. package/types/panel/authorroutes.d.ts +17 -14
  47. package/types/panel/runroutes.d.ts +2 -2
  48. package/types/panel/server.d.ts +155 -2
  49. package/types/panel/status.d.ts +51 -0
  50. package/types/panelmoney.d.ts +5 -0
  51. package/types/replay.d.ts +21 -1
  52. package/types/rubricstep.d.ts +57 -1
  53. package/types/runlist.d.ts +4 -1
  54. package/types/schedulewords.d.ts +4 -2
  55. package/types/source.d.ts +11 -0
  56. package/types/thinking.d.ts +39 -0
  57. package/types/userrun.d.ts +4 -0
package/src/pagemap.js ADDED
@@ -0,0 +1,264 @@
1
+ // IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", sections 4.1 and 5): THE MAP CALL.
2
+ // ONE paid AI call per page input maps the person's job to the page's OWN words. The AI never grades and never invents: every word it
3
+ // names is checked by plain code against the saved page (src/pagerules.js) before anything else sees the answer. This replaces the
4
+ // search-and-read look (`runInputLook`, src/authorscout.js) as the default `lookFn`; it takes the same arguments and returns the same
5
+ // envelope (`state`, `facts: {rubricFiles, inputs, look}`, `calls`, `raws`, `budgetStop`), so the phase line, the spend booking and
6
+ // every downstream reader keep working. The prompt is ported from the POC call (.claude/scratch/idea-a-v2/mapcall.mjs, sha256 recorded
7
+ // in commit 0bcc5fe).
8
+
9
+ import { createRequire } from 'node:module';
10
+ import { priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
11
+ import { redactSecrets } from './validate.js';
12
+ import { isTransportFailure } from './transport.js';
13
+ import { isRateLimited, rateLimitWaitMs } from './ratelimit.js';
14
+ import { isOutputCutOff } from './authorflow.js';
15
+ import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
16
+ import { lineRules, boundInWords, wordsSaidOn } from './rubricstep.js';
17
+ import { applyPageRules, validReading } from './pagerules.js';
18
+ import { ruleWords } from './facts.js';
19
+ import { lookFactOf } from './inputlook.js';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const { Loop } = require('bare-agent');
23
+
24
+ /** the label of the one paid call, as the drafting book and the progress line see it */
25
+ export const MAP_CALL_LABEL = 'page-map';
26
+ /** the shapes the map may name; a `place` is read by code as a phrase */
27
+ export const MAP_SHAPES = Object.freeze(['place', 'money', 'time', 'phrase']);
28
+
29
+ export const MAP_SYSTEM = 'You map a person\'s job to the exact words of ONE saved web page. You never grade and never invent words: every string you return must be copied from the page text, character for character.';
30
+
31
+ /**
32
+ * What the AI is told, for ONE page input. Ported from the POC prompt: the job, the `~` rules, "a result is one item", the answer form
33
+ * and the shape meanings.
34
+ * @param {{jobLines: {n: number, text: string, rule?: string, rules?: string[]}[], label: string, text: string}} o
35
+ * @returns {string}
36
+ */
37
+ export function mapPrompt({ jobLines, label, text }) {
38
+ const job = jobLines.map((l) => `LINE ${l.n}: ${l.text}${lineRules(l).map((r, i) => `\n~ rule ${i + 1}: ${r}`).join('')}`).join('\n');
39
+ return `THE JOB (each ~ rule applies to the line above it):\n${job}\n\n`
40
+ + 'A "result" is one item the page lists (one flight, one stay, one row). For each job line and for each rule, say which fact(s) every result must carry, and give the PAGE\'S OWN WORDS for it.\n\n'
41
+ + 'Return ONLY a JSON object, no prose, no code fence:\n'
42
+ + '{"facts":[{"name":"<letters and spaces only>","from":"<line N | line N rule M>","shape":"place|money|time|phrase","words":["<page words>", ...],"currency":["<symbol>"],"unit":["<words after the amount>"],"reading":<optional, see below>}]}\n\n'
43
+ + 'shape meanings:\n'
44
+ + '- place: words = ways the page writes the place (a city, an airport name), copied from the page.\n'
45
+ + '- money: words = the label(s) written right BEFORE the amount, copied from the page. currency / unit = how the page writes the currency ("$") and/or the words after the amount ("US dollars"); give every spelling the page uses, as lists.\n'
46
+ + '- time: words = the label(s) written right BEFORE the time that is the one the rule is about, copied from the page.\n'
47
+ + '- phrase: words = fixed words a result\'s text must contain, copied from the page.\n'
48
+ + 'Several words in one list are alternatives for the same fact. Put the best one first.\n'
49
+ + 'reading (optional, only on a time or money fact): when a ~ rule is a limit on that fact that the page cannot hold in words (for example "not a red-eye" is a limit on the departure time), give your own best reading of the rule as that limit: {"op":"between","lo":<bound>,"hi":<bound>} or {"op":"lt|lte|gt|gte","value":<bound>}. A time bound is a "HH:MM" string on a 24-hour clock; a money bound is a number. Leave reading out when a page word already carries the rule.\n\n'
50
+ + `PAGE TEXT (the input "${label}"):\n${text}`;
51
+ }
52
+
53
+ /**
54
+ * Read the AI's answer: ONE JSON object of the answer form, and nothing else. No repair (hamr's ruling): text that is not JSON, JSON of
55
+ * another form, or a fact whose shape or lists are not of the form is a malformed answer.
56
+ * @param {unknown} text @returns {{ok: true, mapping: import('./pagerules.js').PageMap}|{ok: false}}
57
+ */
58
+ export function parseMapAnswer(text) {
59
+ /** @type {any} */
60
+ let m;
61
+ try { m = JSON.parse(String(text ?? '').trim()); } catch { return { ok: false }; }
62
+ const strs = (/** @type {unknown} */ v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
63
+ if (m === null || typeof m !== 'object' || Array.isArray(m) || !Array.isArray(m.facts)) return { ok: false };
64
+ for (const f of m.facts) {
65
+ if (f === null || typeof f !== 'object' || Array.isArray(f)) return { ok: false };
66
+ if (typeof f.name !== 'string' || !MAP_SHAPES.includes(f.shape) || !strs(f.words)) return { ok: false };
67
+ if (f.from !== undefined && typeof f.from !== 'string') return { ok: false };
68
+ for (const k of ['currency', 'unit']) if (f[k] !== undefined && !strs(f[k])) return { ok: false };
69
+ // PRD v1.107 Part 5b: a `reading` is a limit of the signed-rule shape on a time or money fact; any other is a malformed answer (no repair)
70
+ if (f.reading !== undefined && !validReading(f.shape, f.reading)) return { ok: false };
71
+ }
72
+ return { ok: true, mapping: m };
73
+ }
74
+
75
+ /** the default loop seam - one spelling of how this repo drives a model */
76
+ const defaultLoop = (/** @type {any} */ { system, onLlmResult, provider, rates }) =>
77
+ new Loop({ provider, system, onLlmResult, ...(rates ? { rates } : {}) });
78
+
79
+ /** the plain-word stop when the answer was not readable twice (no Resume: a draft is not a run) */
80
+ export const MAP_UNREADABLE = 'The AI\'s answer about the page was not readable twice. Nothing was signed.';
81
+
82
+ /**
83
+ * Run the map: one call per page input.
84
+ * @param {{texts: Record<string, string>, inputs: {n: number, label: string}[], jobLines: {n: number, text: string, rule?: string, rules?: string[]}[],
85
+ * provider?: any, maxTokens?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
86
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
87
+ * createLoop?: (o: {system: string, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
88
+ * rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>, prove?: Function}} o
89
+ * @returns {Promise<{state: 'PRESENT'|'ABSENT', facts: any, reason: string|null, cause: string|null, meta: any, raw: string,
90
+ * raws: ReturnType<typeof scrubRaw>[], calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
91
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
92
+ */
93
+ export async function runPageMap({
94
+ texts, inputs, jobLines, provider = null, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null,
95
+ callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS, onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {},
96
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
97
+ }) {
98
+ const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
99
+ ? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
100
+ const priced = rates ? { rates } : {};
101
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
102
+ const calls = [];
103
+ /** @type {ReturnType<typeof scrubRaw>[]} */
104
+ const raws = [];
105
+ let attempts = 0;
106
+ const record = (/** @type {any} */ r, /** @type {number} */ attempt) => {
107
+ const call = { label: MAP_CALL_LABEL, ...priceOf(r) };
108
+ calls.push(call);
109
+ const raw = scrubRaw({ label: MAP_CALL_LABEL, attempt, text: r?.text });
110
+ raws.push(raw);
111
+ // the scrubbed answer rides the call, so the session's drafting log (draft-log.jsonl) keeps the AI's map and a run can be replayed at $0
112
+ onCall({ ...call, raw: raw.text, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
113
+ };
114
+ const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
115
+ state: /** @type {'ABSENT'} */ ('ABSENT'), facts: null, reason, cause, meta: { attempts }, raw: '', raws, calls, budgetStop,
116
+ });
117
+ const halted = () => capStop({ ceilingUsd, ...tallyCalls(calls) });
118
+ const labels = Object.keys(texts);
119
+ /** @type {any[]} */
120
+ const looks = [];
121
+ /** @type {any[]} */
122
+ const dropped = [];
123
+ /** @type {any[]} */
124
+ const facts = [];
125
+ /** @type {any[]} */
126
+ const maps = [];
127
+
128
+ for (const label of labels) {
129
+ const content = mapPrompt({ jobLines, label, text: texts[label] });
130
+ /** @type {any} */
131
+ let mapping = null;
132
+ let malformed = 0;
133
+ let transportRetried = false;
134
+ let rateRetried = false;
135
+ while (mapping === null) {
136
+ // the cap is checked BEFORE every call, never inside one
137
+ const halt = halted();
138
+ if (halt) return absent(halt === 'cap-halt' ? `the AI's look at the page was not made: the $${ceilingUsd} ceiling was already spent` : 'the AI\'s look at the page was not made: spend could not be priced, so the ceiling could not be enforced', SURVEY_CAUSES.NOT_FUNDED, halt);
139
+ attempts += 1;
140
+ const loop = createLoop({ system: MAP_SYSTEM, onLlmResult: undefined, provider, ...priced });
141
+ /** @type {any} */
142
+ let r;
143
+ try {
144
+ r = await loop.run([{ role: 'user', content }], [], { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions });
145
+ } catch (e) {
146
+ if (isRateLimited(e) && !rateRetried) {
147
+ // a 429 is a REFUSAL (nothing billed): wait the vendor's stated time once, then ask again
148
+ const wait = rateLimitWaitMs(e);
149
+ if (wait !== null) { rateRetried = true; await sleep(wait); continue; }
150
+ }
151
+ const casualty = callCasualty(e) ?? (isTransportFailure(e) ? String(/** @type {any} */ (e)?.message ?? e) : null);
152
+ if (casualty === null) throw e;
153
+ // the failed attempt may already be billed: it is booked UNPRICED, so the spend stays "not complete" (never a $0)
154
+ record({ error: casualty }, attempts);
155
+ if (!transportRetried) { transportRetried = true; continue; }
156
+ return absent(`the call about the page failed: ${redactSecrets(casualty)}`, SURVEY_CAUSES.CALL_FAILED);
157
+ }
158
+ record(r, attempts);
159
+ if (r?.error && !isOutputCutOff(r)) return absent(`the call about the page failed: ${redactSecrets(String(r.error))}`, SURVEY_CAUSES.CALL_FAILED);
160
+ // a truncated answer (max_tokens) is a malformed one
161
+ const parsed = isOutputCutOff(r) ? { ok: false } : parseMapAnswer(r?.text);
162
+ if (parsed.ok) { mapping = /** @type {any} */ (parsed).mapping; break; }
163
+ malformed += 1;
164
+ if (malformed >= 2) return absent(MAP_UNREADABLE, SURVEY_CAUSES.UNPARSEABLE);
165
+ }
166
+ const kept = applyPageRules(texts[label], mapping, jobLines);
167
+ for (const f of kept.facts) facts.push(lookFactOf(f, label));
168
+ for (const d of kept.dropped) dropped.push({ ...d, source: label });
169
+ maps.push({ source: label, facts: mapping.facts.length });
170
+ looks.push(label);
171
+ }
172
+ return {
173
+ state: 'PRESENT', reason: null, cause: null, meta: { attempts, maps }, raw: '', raws, calls, budgetStop: null,
174
+ facts: { rubricFiles: true, inputs: inputs.map((i) => ({ n: i.n, label: i.label })), look: { facts, prose: [], notFound: [], dropped } },
175
+ };
176
+ }
177
+
178
+ /** the answer that drops a `~` rule the page cannot check: the person types it (any case, a closing full stop allowed) */
179
+ export const DROP_IT = 'drop it';
180
+ /** @param {unknown} text @returns {boolean} */
181
+ export const isDropIt = (text) => String(text ?? '').trim().replace(/\.$/, '').toLowerCase() === DROP_IT;
182
+ /** the answers that accept the code's reading of a rule; anything else the person types is their own reading */
183
+ const AGREE_WORDS = ['agree', 'yes'];
184
+ /** @param {unknown} text @returns {boolean} */
185
+ export const isAgree = (text) => AGREE_WORDS.includes(String(text ?? '').trim().replace(/[.!]$/, '').toLowerCase());
186
+
187
+ /** @typedef {{line: number, rule: number, source: 'look', question: string, ask: 'reading'|'cant-check', ruleText: string, reading?: string}} LookAsk
188
+ * `reading` = the plain words an `agree` writes as a `~` rule (a reading ask only) */
189
+
190
+ /** the words of a reading ask: the ONE spelling, and {@link readingOfQuestion} reads it back (the Result tab's "(you confirmed)") */
191
+ const READING_HEAD = ' I read it as: ';
192
+ const READING_TAIL = '. Agree? Answer "agree"';
193
+ /** @param {number} n @param {string} rule @param {string} reading @returns {string} */
194
+ const readingQuestion = (n, rule, reading) => `Your line ${n} says "${rule}".${READING_HEAD}${reading}${READING_TAIL}, or type your own reading (your answer is added as a new ~ rule on line ${n}).`;
195
+ /** @param {unknown} q a recorded question @returns {string|null} the reading a reading ask proposed, or null when it was another kind of question */
196
+ export function readingOfQuestion(q) {
197
+ const s = String(q ?? '');
198
+ const a = s.indexOf(READING_HEAD);
199
+ const b = s.indexOf(READING_TAIL);
200
+ return a >= 0 && b > a ? s.slice(a + READING_HEAD.length, b) : null;
201
+ }
202
+
203
+ /**
204
+ * PRD v1.107 Part 5b ("the look reads, code asks"): the early asks CODE forces before any paid drafting, from the look's own result. Every `~` rule is
205
+ * one of three kinds:
206
+ * - PAGE: a kept page word carries it (a kept fact with `forRule` and no `reading`): no ask.
207
+ * - READING: a kept fact carries it with a `reading` (a limit the page cannot hold, e.g. "not a red-eye" -> departure time between 06:00 and 21:00).
208
+ * Asked only when a bound of the reading is NOT in the words the person wrote (the same test as `rubric-fact-unmarked`, `boundInWords`); a
209
+ * bound they wrote themselves is theirs and nothing is asked. `agree` writes the reading as a `~` rule in plain words (the answer text is
210
+ * `reading`), so the drafter is told it and the fact signs plain.
211
+ * - CAN'T CHECK: no kept fact carries it (PRD v1.106 Part 5 Q3 T2). `drop it` leaves it Not checked.
212
+ * One ask per rule text on a line; readings first, then can't-check asks, each in line order. The caller applies the machine's cap and the answer gate.
213
+ * @param {{facts?: {line: number, forRule?: number, name?: string, shape?: string, unit?: string[], currency?: string[], reading?: import('./pagerules.js').Reading}[]}|null|undefined} look
214
+ * @param {{n: number, text?: string, rule?: string, rules?: string[]}[]|null|undefined} jobLines
215
+ * @returns {LookAsk[]}
216
+ */
217
+ export function askableFromLook(look, jobLines) {
218
+ const facts = Array.isArray(look?.facts) ? look.facts : [];
219
+ /** @type {LookAsk[]} */
220
+ const readings = [];
221
+ /** @type {LookAsk[]} */
222
+ const cants = [];
223
+ /** @type {Set<string>} */
224
+ const seen = new Set();
225
+ for (const l of Array.isArray(jobLines) ? jobLines : []) {
226
+ lineRules(l).forEach((rule, k) => {
227
+ const n = k + 1;
228
+ const key = `${l.n}\u0000${rule.trim().toLowerCase()}`;
229
+ const tied = facts.filter((f) => f.line === l.n && f.forRule === n);
230
+ if (tied.length > 0) {
231
+ const said = wordsSaidOn(l);
232
+ const f = tied.find((x) => x.reading && validReading(x.shape, x.reading) && ruleBounds(x.reading).some((b) => !boundInWords(b, said)));
233
+ if (f === undefined || seen.has(key)) return;
234
+ seen.add(key);
235
+ const reading = `${f.name} ${ruleWords({ shape: f.shape, unit: f.unit, currency: f.currency, rule: f.reading })}`;
236
+ readings.push({
237
+ line: l.n, rule: n, source: 'look', ask: 'reading', ruleText: rule, reading,
238
+ question: readingQuestion(l.n, rule, reading),
239
+ });
240
+ return;
241
+ }
242
+ if (seen.has(key)) return;
243
+ seen.add(key);
244
+ cants.push({
245
+ line: l.n, rule: n, source: 'look', ask: 'cant-check', ruleText: rule,
246
+ question: `Your line ${l.n} says "${rule}". I can't check this on the page. Drop it? Answer "${DROP_IT}", or type how to check it (your answer is added as a new ~ rule on line ${l.n}).`,
247
+ });
248
+ });
249
+ }
250
+ return [...readings, ...cants];
251
+ }
252
+
253
+ /** @param {import('./pagerules.js').Reading} r @returns {unknown[]} the bounds a reading holds */
254
+ const ruleBounds = (r) => (r.op === 'between' ? [r.lo, r.hi] : [r.value]);
255
+
256
+ /**
257
+ * PRD v1.107 Part 5b: more asks than the machine's cap means the job needs a rewrite, not a questionnaire. The code-owned stop words list EVERY ask and
258
+ * its reading as the suggestion (no extra paid call). A secret in a rule is masked.
259
+ * @param {LookAsk[]} asks @returns {string}
260
+ */
261
+ export function tooManyAsksText(asks) {
262
+ const list = asks.map((a) => `line ${a.line} "${a.ruleText}" → ${a.reading ?? "I can't check this on the page"}`).join('; ');
263
+ return redactSecrets(`Your job has ${asks.length} rules I can't check or had to guess. Please rewrite it so each one says what to look for. My readings: ${list}.`);
264
+ }
@@ -0,0 +1,319 @@
1
+ // IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", section 4.2): the plain code rules that
2
+ // keep or drop each page word the AI's map named. The AI never grades: it names words, code keeps only what the saved page proves.
3
+ // Pure - no I/O, no clock, no model. The rule bodies are PORTED from the frozen POC (.claude/scratch/idea-a-v2/rules.mjs and
4
+ // rules-v3-candidate.mjs; sha256 recorded in commit 0bcc5fe), with the OUT branches (R2, R2a, R2b, R2c, R3, R3U, V4, pageClaims) deleted.
5
+
6
+ import { lineRules } from './rubricstep.js';
7
+ import { MAX_CURRENCIES, MAX_UNITS, MAX_UNIT_CHARS, FACT_OPS, boundOk } from './facts.js';
8
+ import { countExact, countPhrase, squash } from './factwords.js';
9
+ import { normLabel } from './labelscan.js';
10
+
11
+ /**
12
+ * @typedef {{op: string, value?: string|number, lo?: string|number, hi?: string|number}} Reading what a `~` rule means as a limit (PRD v1.107 Part 5b): the same shape as a signed fact rule
13
+ * @typedef {{name: string, from?: string, shape: string, words?: string[], currency?: string[], unit?: string[], reading?: Reading}} MapFact the AI's map, one fact
14
+ * @typedef {{facts: MapFact[]}} PageMap
15
+ * @typedef {{line: number, name: string, shape: string, from: string, words: string[], unit?: string[], currency?: string[], forRule?: number, reading?: Reading}} KeptFact
16
+ * `words` = what the page proves: the labels of a money/number/time fact, or the alternative phrases of a phrase fact (a place is a phrase)
17
+ * @typedef {{line: number, rule?: number, fact: string, word?: string, reason: string, other?: string}} Dropped `other` = what the drop is compared to: the first label (R1), the primary form (R1L), the fact it would have repeated (S1)
18
+ */
19
+
20
+ /** @param {unknown} v @returns {v is Record<string, any>} */
21
+ const isRec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
22
+
23
+ /**
24
+ * Which job line, and which `~` rule under it, a map fact carries out. The map writes `from` as "line N", "line N rule M", "rule M" or
25
+ * "job line"; anything else is the first job line.
26
+ * @param {unknown} from @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
27
+ * @returns {{line: number, rule?: number}}
28
+ */
29
+ export function tieOf(from, jobLines) {
30
+ const s = String(from ?? '');
31
+ const lineM = /line\s*(\d+)/i.exec(s);
32
+ const ruleM = /rule\s*(\d+)/i.exec(s);
33
+ const first = jobLines[0]?.n ?? 1;
34
+ let line = lineM && jobLines.some((l) => l.n === Number(lineM[1])) ? Number(lineM[1]) : null;
35
+ const rule = ruleM ? Number(ruleM[1]) : undefined;
36
+ if (line === null && rule !== undefined) line = jobLines.find((l) => lineRules(l).length >= rule)?.n ?? null;
37
+ const out = { line: line ?? first };
38
+ if (rule !== undefined && lineRules(jobLines.find((l) => l.n === out.line)).length >= rule) return { ...out, rule };
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Is this a legal `reading` for a fact of this shape? Only a time or money fact carries one, with the op set and the bound types of a signed
44
+ * fact rule (src/facts.js): no other key, no missing bound. A malformed one makes the whole map answer malformed (no repair).
45
+ * @param {unknown} shape @param {unknown} r @returns {boolean}
46
+ */
47
+ export function validReading(shape, r) {
48
+ if (shape !== 'time' && shape !== 'money') return false;
49
+ if (!isRec(r) || !FACT_OPS.includes(r.op)) return false;
50
+ const keys = Object.keys(r).sort().join();
51
+ return r.op === 'between' ? keys === 'hi,lo,op' && boundOk(shape, r.lo) && boundOk(shape, r.hi) : keys === 'op,value' && boundOk(shape, r.value);
52
+ }
53
+
54
+ /** @param {string} text @returns {string[]} the page's lines, white space squashed (the unit every per-line rule counts in) */
55
+ export const linesOf = (text) => text.split('\n').map(squash);
56
+
57
+ /**
58
+ * How many times a label occurs inside ONE page line, at most (plain indexOf on squashed lines, non-overlapping): the N2 measure.
59
+ * @param {string[]} lines @param {string} phrase @returns {number}
60
+ */
61
+ function maxPerLine(lines, phrase) {
62
+ const p = squash(phrase).trim();
63
+ if (!p) return 0;
64
+ let max = 0;
65
+ for (const ln of lines) {
66
+ let n = 0;
67
+ for (let i = ln.indexOf(p); i !== -1; i = ln.indexOf(p, i + p.length)) n += 1;
68
+ if (n > max) max = n;
69
+ }
70
+ return max;
71
+ }
72
+
73
+ /** the value each shape reads right after a label: a money or number is digits (a sign may come first), a time is h:mm and AM/PM */
74
+ const READ = {
75
+ money: /^\s*[$€£]?\s*(\d[\d,]*(?:\.\d+)?)/,
76
+ time: /^\s*(\d{1,2}:\d{2}[\s\u202f\u00a0]?[AaPp][Mm])/,
77
+ number: /^\s*(\d[\d,]*(?:\.\d+)?)/,
78
+ };
79
+
80
+ /**
81
+ * R1 reader: the set of values of a fact's shape written right after each occurrence of a label on the page.
82
+ * @param {string[]} lines @param {string} label @param {'money'|'time'|'number'} shape @returns {Set<string>}
83
+ */
84
+ function valueSet(lines, label, shape) {
85
+ /** @type {Set<string>} */
86
+ const s = new Set();
87
+ const p = squash(label).trim();
88
+ if (!p) return s;
89
+ for (const ln of lines) {
90
+ for (let i = ln.indexOf(p); i !== -1; i = ln.indexOf(p, i + p.length)) {
91
+ const m = READ[shape].exec(ln.slice(i + p.length));
92
+ if (m) s.add(m[1]);
93
+ }
94
+ }
95
+ return s;
96
+ }
97
+ /** @param {Set<string>} a @param {Set<string>} b @returns {boolean} */
98
+ const sameSet = (a, b) => a.size === b.size && [...a].every((x) => b.has(x));
99
+
100
+ /** a number as the page writes it: digits, thousands commas, optional decimals (a currency sign next to it is not part of it) */
101
+ const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
102
+ const BEFORE = new RegExp(`(${NUM})\\s*$`);
103
+ const AFTER = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
104
+ const AFTER_CUR = new RegExp(`^\\s*(${NUM})`);
105
+ const stripCommas = (/** @type {string} */ s) => s.replace(/,/g, '');
106
+
107
+ /** @param {string[]} lines @param {string} w @returns {[string, number][]} every occurrence of w as [its line, where it starts] */
108
+ function occs(lines, w) {
109
+ /** @type {[string, number][]} */
110
+ const out = [];
111
+ for (const l of lines) for (let i = l.indexOf(w); i !== -1; i = l.indexOf(w, i + w.length)) out.push([l, i]);
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * U1: how many times a label has a number right BEFORE it (whitespace only between) and right AFTER it (whitespace and one currency sign
117
+ * between). A marker the page writes after the number ("490 total") has B > 0 and A = 0.
118
+ * @param {string[]} lines @param {string} label @returns {{B: number, A: number}}
119
+ */
120
+ function beforeAfter(lines, label) {
121
+ let B = 0;
122
+ let A = 0;
123
+ for (const [l, i] of occs(lines, label)) {
124
+ if (BEFORE.test(l.slice(0, i))) B += 1;
125
+ if (AFTER.test(l.slice(i + label.length))) A += 1;
126
+ }
127
+ return { B, A };
128
+ }
129
+ /** R1L: the numbers a UNIT form reads - the one right before each occurrence @param {string[]} lines @param {string} u @returns {Set<string>} */
130
+ const unitSet = (lines, u) => new Set(occs(lines, u).map(([l, i]) => BEFORE.exec(l.slice(0, i))?.[1]).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
131
+ /** R1L: the numbers a CURRENCY form reads - the one right after each occurrence @param {string[]} lines @param {string} c @returns {Set<string>} */
132
+ const curSet = (lines, c) => new Set(occs(lines, c).map(([l, i]) => AFTER_CUR.exec(l.slice(i + c.length))?.[1]).filter((v) => typeof v === 'string').map((v) => stripCommas(/** @type {string} */ (v))));
133
+
134
+ /** S1: a fact name that counts results ("the second flight") cannot be checked per result - one result is one record, ask for N results instead */
135
+ const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'latest', 'earliest', 'newest', 'oldest', 'last', 'next', 'previous', 'final', 'nth'];
136
+
137
+ /** the symbols a currency may be (src/facts.js); a code like "EUR" is a word read after the number, so it is a unit */
138
+ const CURRENCY_SYMBOLS = ['$', '€', '£'];
139
+ /** the shapes that read a value after a label */
140
+ const VALUE_SHAPES = ['money', 'time', 'number'];
141
+
142
+ /**
143
+ * Apply the rules to the AI's map over one saved page. Returns the kept facts and, for everything a rule removed, one `dropped` entry.
144
+ * The order of the rules is the POC's (apply-v3.mjs): found-on-page, then the rules that read the page. A place is read as a phrase.
145
+ * @param {string} text the frozen page text @param {PageMap} mapping @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
146
+ * @returns {{facts: KeptFact[], dropped: Dropped[]}}
147
+ */
148
+ export function applyPageRules(text, mapping, jobLines) {
149
+ /** @type {Dropped[]} */
150
+ const dropped = [];
151
+ const all = Array.isArray(mapping?.facts) ? mapping.facts.filter(isRec) : [];
152
+ /** @type {Map<MapFact, {line: number, rule?: number}>} */
153
+ const ties = new Map(all.map((f) => [f, tieOf(f.from, jobLines)]));
154
+ const drop = (/** @type {MapFact} */ f, /** @type {string|undefined} */ word, /** @type {string} */ reason, /** @type {string|undefined} */ other = undefined) => {
155
+ const t = /** @type {{line: number, rule?: number}} */ (ties.get(f));
156
+ dropped.push({ line: t.line, ...(t.rule === undefined ? {} : { rule: t.rule }), fact: String(f.name ?? ''), ...(word === undefined ? {} : { word }), reason, ...(other === undefined ? {} : { other }) });
157
+ };
158
+
159
+ // ---- S1, name part: a fact whose name counts results is refused before anything else reads it ----
160
+ const list = all.filter((f) => {
161
+ const hit = String(f.name ?? '').toLowerCase().split(/[^a-z]+/).find((w) => ORDINALS.includes(w));
162
+ if (hit === undefined) return true;
163
+ drop(f, undefined, 's1-name');
164
+ return false;
165
+ });
166
+
167
+ // ---- found-on-page, then U1 and R1L for the facts that read an amount ----
168
+ const lines = linesOf(text);
169
+ /** @type {{f: MapFact, kept: string[], cur: string[], uni: string[], first?: string|null, anchorless?: boolean}[]} */
170
+ const pending = [];
171
+ for (const f of list) {
172
+ const words = (Array.isArray(f.words) ? f.words : []).map((w) => String(w)).filter((w) => w.trim() !== '');
173
+ if (f.shape === 'place' || f.shape === 'phrase') {
174
+ const kept = [];
175
+ for (const w of words) {
176
+ const n = f.shape === 'phrase' ? countPhrase(text, w) : countExact(text, w);
177
+ if (n >= 1) kept.push(w); else drop(f, w, 'not-on-page');
178
+ }
179
+ pending.push({ f, kept, cur: [], uni: [] });
180
+ } else if (VALUE_SHAPES.includes(String(f.shape))) {
181
+ const amount = f.shape === 'money' || f.shape === 'number';
182
+ /** labels the page holds (and, for an amount, that have a number right after them) */
183
+ const labels = [];
184
+ /** the AI's labels as U1 leaves them, a word the page does not hold included: the first of these is R1's reference */
185
+ const asListed = [];
186
+ /** U1: labels the page writes after the number */
187
+ const moved = [];
188
+ for (const w0 of words) {
189
+ const w = normLabel(w0);
190
+ if (!w) continue;
191
+ if (countExact(text, w) < 1) { asListed.push(w0); drop(f, w0, 'not-on-page'); continue; }
192
+ if (amount) {
193
+ const { B, A } = beforeAfter(lines, w);
194
+ if (A === 0 && B > 0) { moved.push(w); continue; }
195
+ if (A === 0 && B === 0) { drop(f, w, 'no-value'); continue; }
196
+ }
197
+ asListed.push(w0);
198
+ labels.push(w);
199
+ }
200
+ /** @type {string[]} */ let uni = [];
201
+ /** @type {string[]} */ let cur = [];
202
+ if (f.shape === 'money') {
203
+ // the forms: units first (the U1-moved words, then the AI's listed units), then currency signs
204
+ const listedCurrencies = (Array.isArray(f.currency) ? f.currency : []).map(String);
205
+ // a currency SIGN listed in BOTH unit and currency is said once: the unit copy goes (fwd F71 drafts)
206
+ const listedUnits = (Array.isArray(f.unit) ? f.unit : []).map(String).filter((u) => {
207
+ if (!(CURRENCY_SYMBOLS.includes(u) && listedCurrencies.includes(u))) return true;
208
+ drop(f, u, 'sign-twice');
209
+ return false;
210
+ });
211
+ const units = [...new Set([...moved, ...listedUnits])].filter((u) => {
212
+ if (countPhrase(text, u) >= 1) return true;
213
+ drop(f, u, 'not-on-page');
214
+ return false;
215
+ });
216
+ const curs = listedCurrencies.filter((c) => {
217
+ if (countExact(text, c) >= 1) return true;
218
+ drop(f, c, 'not-on-page');
219
+ return false;
220
+ });
221
+ /** @type {{kind: 'unit'|'currency', w: string}[]} */
222
+ const forms = [...units.map((u) => /** @type {const} */ ({ kind: 'unit', w: u })), ...curs.map((c) => /** @type {const} */ ({ kind: 'currency', w: c }))];
223
+ if (forms.length) {
224
+ // R1L: every form reads the same set of numbers as the primary (the first form); one that reads more or fewer is dropped
225
+ const read = (/** @type {{kind: string, w: string}} */ x) => (x.kind === 'unit' ? unitSet(lines, x.w) : curSet(lines, x.w));
226
+ const ps = read(forms[0]);
227
+ const keep = [forms[0]];
228
+ // the same word listed as both a unit and a currency is ONE form: it is never compared with itself, only said (the unit, listed first, stays)
229
+ for (const x of forms.slice(1)) { if (x.w !== forms[0].w && sameSet(read(x), ps)) keep.push(x); else drop(f, x.w, 'r1l', forms[0].w); }
230
+ uni = keep.filter((x) => x.kind === 'unit').map((x) => x.w);
231
+ cur = keep.filter((x) => x.kind === 'currency').map((x) => x.w);
232
+ }
233
+ // a currency that is a word ("US dollars") is read after the number, so it is a unit, never a currency sign
234
+ cur = cur.filter((c) => {
235
+ if (CURRENCY_SYMBOLS.includes(c)) return true;
236
+ drop(f, c, 'currency-not-symbol');
237
+ return false;
238
+ });
239
+ // the guard's list limits (src/facts.js), applied here so a longer list is cut by code and SAID, never refused later: the first forms stay
240
+ uni = uni.filter((u, i) => {
241
+ if (u.length > MAX_UNIT_CHARS) { drop(f, u, 'unit-too-long'); return false; }
242
+ if (i >= MAX_UNITS) { drop(f, u, 'limit-unit'); return false; }
243
+ return true;
244
+ });
245
+ cur = cur.filter((c, i) => {
246
+ if (i < MAX_CURRENCIES) return true;
247
+ drop(f, c, 'limit-currency');
248
+ return false;
249
+ });
250
+ }
251
+ pending.push({ f, kept: labels, cur, uni, first: asListed.length ? normLabel(asListed[0]) : null, anchorless: amount && asListed.length === 0 });
252
+ } else drop(f, undefined, 'bad-shape');
253
+ }
254
+
255
+ // ---- R1, same-value: the labels after the AI's FIRST listed one are kept only if they read the same set of values as it does ----
256
+ for (const p of pending) {
257
+ if (!VALUE_SHAPES.includes(String(p.f.shape))) continue;
258
+ const shape = /** @type {'money'|'time'|'number'} */ (p.f.shape);
259
+ const ref = p.first ? valueSet(lines, p.first, shape) : new Set();
260
+ p.kept = p.kept.filter((w) => {
261
+ if (w === p.first) return true;
262
+ if (sameSet(valueSet(lines, w, shape), ref)) return true;
263
+ drop(p.f, w, 'r1', p.first ?? undefined);
264
+ return false;
265
+ });
266
+ }
267
+
268
+ // ---- N2, per line: a label written 2+ times inside one page line cannot say which value is meant, so it is unusable. (R2 grow-left is OUT.) ----
269
+ for (const p of pending) {
270
+ if (!VALUE_SHAPES.includes(String(p.f.shape))) continue;
271
+ p.kept = p.kept.filter((w) => {
272
+ if (maxPerLine(lines, w) <= 1) return true;
273
+ drop(p.f, w, 'n2');
274
+ return false;
275
+ });
276
+ }
277
+
278
+ // ---- the kept facts, in the POC's order: places, phrases, then values ----
279
+ /** @type {KeptFact[]} */
280
+ const signed = [];
281
+ const make = (/** @type {{f: MapFact, kept: string[], cur: string[], uni: string[]}} */ p) => {
282
+ const t = /** @type {{line: number, rule?: number}} */ (ties.get(p.f));
283
+ /** @type {KeptFact} */
284
+ const out = { line: t.line, name: String(p.f.name), shape: p.f.shape === 'place' ? 'phrase' : String(p.f.shape), from: String(p.f.from ?? ''), words: [...p.kept] };
285
+ if (p.f.shape === 'money') {
286
+ if (p.uni.length) out.unit = [...p.uni];
287
+ if (p.cur.length) out.currency = [...p.cur];
288
+ }
289
+ if (t.rule !== undefined) out.forRule = t.rule;
290
+ if (validReading(out.shape, p.f.reading)) out.reading = /** @type {Reading} */ ({ ...p.f.reading });
291
+ return out;
292
+ };
293
+ for (const kind of ['place', 'phrase', 'value']) {
294
+ for (const p of pending) {
295
+ const isValue = VALUE_SHAPES.includes(String(p.f.shape));
296
+ if (kind === 'value' ? !isValue : p.f.shape !== kind) continue;
297
+ // a fact left with no label is dropped, unless U1 moved every label to a unit/currency form of a money fact (it then reads that form
298
+ // alone); a money fact also needs a currency or a unit the page holds
299
+ const forms = p.cur.length + p.uni.length;
300
+ const ok = p.f.shape === 'money' ? forms > 0 && (p.kept.length > 0 || p.anchorless === true) : p.kept.length > 0;
301
+ if (!ok) { drop(p.f, undefined, 'no-label'); continue; }
302
+ signed.push(make(p));
303
+ }
304
+ }
305
+ // ---- S1, shape and label part: two facts of one result may not share a shape AND a label (they would read the same value and can never differ).
306
+ // The first one stays; a fact with no label is keyed by its first unit or currency, so two different amounts are never confused. ----
307
+ /** @type {Map<string, string>} */
308
+ const seen = new Map();
309
+ const kept = signed.filter((k) => {
310
+ const key = `${k.shape}\u0000${k.words[0] ?? k.unit?.[0] ?? k.currency?.[0] ?? ''}`;
311
+ const first = seen.get(key);
312
+ if (first === undefined) { seen.set(key, k.name); return true; }
313
+ const f = /** @type {MapFact} */ (all.find((x) => String(x.name) === k.name));
314
+ drop(f, undefined, 's1', first);
315
+ return false;
316
+ });
317
+ const order = list.map((f) => String(f.name));
318
+ return { facts: kept.sort((a, b) => order.findIndex((n) => a.name.startsWith(n)) - order.findIndex((n) => b.name.startsWith(n))), dropped };
319
+ }