clearotron 0.4.0-beta.2 → 0.4.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/THIRD-PARTY-NOTICES.md +5 -5
  2. package/build-info.json +2 -2
  3. package/driver/CHANGELOG.md +90 -0
  4. package/driver/ask-ledger.mjs +2 -2
  5. package/driver/coverage-form.mjs +6 -0
  6. package/driver/coverage-ledger.mjs +14 -2
  7. package/driver/driver.config.mjs +24 -7
  8. package/driver/engine/mcp/band-server.mjs +3 -3
  9. package/driver/engine/mcp/stdio-server.mjs +12 -1
  10. package/driver/gateway.mjs +27 -3
  11. package/driver/package.json +1 -1
  12. package/driver/pipeline-knockout.mjs +41 -10
  13. package/driver/pipeline.mjs +104 -12
  14. package/driver/progress.mjs +6 -1
  15. package/driver/provider-usage.mjs +16 -0
  16. package/driver/publish/index.mjs +11 -5
  17. package/driver/publish/knockout.mjs +16 -3
  18. package/driver/publish/render-knockout.mjs +38 -11
  19. package/driver/record-carry.mjs +17 -1
  20. package/driver/reference-strip-signatures.mjs +11 -1
  21. package/driver/register-plan.mjs +7 -3
  22. package/driver/register-served.mjs +91 -0
  23. package/driver/remedy-accounting.mjs +38 -9
  24. package/driver/reviewer-open-points.mjs +20 -23
  25. package/driver/score-redaction.mjs +416 -20
  26. package/driver/screen-gate.mjs +3 -3
  27. package/driver/skills/clearance-register/SKILL.md +0 -8
  28. package/driver/skills/clearance-register/digest.md +1 -1
  29. package/driver/skills/clearance-register/providers/corsearch.md +1 -1
  30. package/driver/skills/clearance-register/register-recipes.md +3 -9
  31. package/driver/skills/clearance-search/SKILL.md +2 -2
  32. package/driver/skills/clearance-search/phase2-execution.md +1 -1
  33. package/driver/skills/knockout-assess/SKILL.md +2 -0
  34. package/driver/stages-knockout.mjs +22 -0
  35. package/driver/suite-census.json +152 -26
  36. package/driver/unit-inventory.mjs +38 -43
  37. package/mcp-server/CHANGELOG.md +8 -0
  38. package/mcp-server/package.json +1 -1
  39. package/node_modules/brace-expansion/index.js +78 -22
  40. package/node_modules/brace-expansion/package.json +1 -1
  41. package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
  42. package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
  43. package/package.json +1 -1
  44. package/portal-ui/package.json +2 -2
  45. package/providers/_shared/term-shape.mjs +1 -1
  46. package/providers/jx/src/core.js +0 -1
  47. package/providers/jx/src/judge.js +0 -1
  48. package/providers/jx/src/nativeread.js +0 -1
  49. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  50. package/providers/oauth-mcp-bridge/package.json +1 -1
  51. package/providers/signa/src/core.js +125 -17
  52. package/scripts/e2e-first-time.mjs +12 -4
  53. package/scripts/e2e-scenario-ops.mjs +25 -2
  54. package/scripts/e2e.mjs +246 -24
  55. package/scripts/mint-reference-strip-backlog.mjs +36 -2
  56. package/scripts/score.mjs +102 -29
  57. package/shared/identifier-scan.mjs +31 -1
@@ -80,7 +80,7 @@ export function termPredicateIssue(term, predicate) {
80
80
  // R2b died at fan-in on a plan carrying its own section headings as search terms:
81
81
  //
82
82
  // term="**Core (BIOVELTRIN, BIO VELTRIN, BIO-VELTRIN, etc.)**" predicate=default
83
- // term="**Formative root (VELTRIN, DELPHIN, DELPHINUS, etc.)**" predicate=default
83
+ // term="**Formative root (VELTRIN, KORPHIN, KORPHINUS, etc.)**" predicate=default
84
84
  //
85
85
  // Twenty minutes earlier the same matter on the same commit delivered clean, and the only difference
86
86
  // was those two strings. Both happened to be 6 words, so they tripped the >4-word arm below by luck;
@@ -120,7 +120,6 @@ export function buildCandidateRequest({ mark, productContext = "", lane = "zh",
120
120
  model,
121
121
  max_tokens: 2048, // 8 Han candidates + rationales need headroom; truncation is checked either way
122
122
  tools: [CANDIDATE_TOOL],
123
- tool_choice: { type: "tool", name: CANDIDATE_TOOL.name }, // FORCED tool answer; shape validated at parse
124
123
  messages: [{ role: "user", content: promptFor({ mark: String(mark).trim(), productContext: String(productContext ?? "").trim() }) }],
125
124
  };
126
125
  }
@@ -79,7 +79,6 @@ export function buildJudgeRequest({ mark, hits, model = DEFAULT_MODEL }) {
79
79
  model,
80
80
  max_tokens: 4096, // 40 one-clause judgments fit well inside; truncation checked either way
81
81
  tools: [JUDGE_TOOL],
82
- tool_choice: { type: "tool", name: JUDGE_TOOL.name }, // FORCED tool answer; shape validated at parse
83
82
  messages: [{ role: "user", content: prompt }],
84
83
  };
85
84
  }
@@ -81,7 +81,6 @@ export function buildNativereadRequest({ mark, lane = "zh", payload, model = DEF
81
81
  model,
82
82
  max_tokens: 4096, // 12 grounded items with grounds quotes need headroom; truncation checked either way
83
83
  tools: [NATIVEREAD_TOOL],
84
- tool_choice: { type: "tool", name: NATIVEREAD_TOOL.name }, // FORCED tool answer; shape validated at parse
85
84
  messages: [{ role: "user", content: prompt }],
86
85
  };
87
86
  }
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.0
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.0-beta.3
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.4.0-beta.2
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -182,6 +182,52 @@ export function rememberableAnswer(method, status, body, parseError) {
182
182
  // answering as though it were the one requested.
183
183
  const DETERMINISTIC_MATCH = new Set(["similar", "exact", "starts_with", "ends_with", "contains"]);
184
184
 
185
+ // ── THE RANKED STRATEGIES, AS SIMILARITY CHANNELS ────────────────────────────────────────────────
186
+ //
187
+ // The register retired `strategies` in favour of `similarity` and retired `query` in favour of `q`, both
188
+ // on the same date, and says so in `search_meta.deprecations` on every response that still uses the old
189
+ // names. This is that migration.
190
+ //
191
+ // THE TABLE IS NOT A RENAME, and assuming it was is the way to lose coverage quietly. Each old strategy
192
+ // expands to a SET of channels, and the sets are not the strategy's own name: `fuzzy` alone applies four.
193
+ // Every row below was read off the register's own `similarity_applied` for the old parameter — so the
194
+ // register derived this table, not us — and each was then confirmed by sending the new form and comparing
195
+ // what came back against the old form's answer, on a neutral term, not by reading the names across:
196
+ //
197
+ // strategy similarity channels
198
+ // exact identical, lookalike
199
+ // phonetic identical, phonetic
200
+ // fuzzy identical, fuzzy, embedded, lookalike
201
+ // prefix identical, embedded
202
+ //
203
+ // For `exact` the comparison was of the returned SETS, paged to exhaustion both ways and compared by
204
+ // record, not of the totals — two totals agreeing is not two sets agreeing. Several strategies in one call
205
+ // UNION their channels, confirmed on a combination the same way. The figures, the date and the term they
206
+ // were taken on are on the tracker: this directory is public and vendor measurements do not live here.
207
+ //
208
+ // `similarity` REFUSES ANYTHING ELSE — "similarity must be one of: identical, fuzzy, embedded, phonetic,
209
+ // lookalike" — which is why `prefix` has no channel of its own and why an unknown strategy must not be
210
+ // forwarded verbatim: it would 4xx the call rather than narrow it, but only at run time and only for the
211
+ // plan that asked. Unknown names map to the exact channels and the caller's word is kept in the request's
212
+ // own record by the plan, not invented here.
213
+ const SIMILARITY_FOR = Object.freeze({
214
+ exact: ["identical", "lookalike"],
215
+ phonetic: ["identical", "phonetic"],
216
+ fuzzy: ["identical", "fuzzy", "embedded", "lookalike"],
217
+ prefix: ["identical", "embedded"],
218
+ });
219
+ const SIMILARITY_CHANNELS = Object.freeze(["identical", "fuzzy", "embedded", "phonetic", "lookalike"]);
220
+
221
+ /** The channels a list of ranked strategies asks for: the union of each one's, in the register's own order. */
222
+ export function similarityFor(strategies) {
223
+ const want = new Set();
224
+ const list = Array.isArray(strategies) && strategies.length ? strategies : ["exact"];
225
+ for (const s of list) {
226
+ for (const c of (SIMILARITY_FOR[String(s ?? "").trim().toLowerCase()] ?? SIMILARITY_FOR.exact)) want.add(c);
227
+ }
228
+ return SIMILARITY_CHANNELS.filter((c) => want.has(c));
229
+ }
230
+
185
231
  // ── filters: ONE builder, because the two shapes drifting apart is how `status` survived ───────────
186
232
  //
187
233
  // `filters.status` was sent by both branches below and NO SUCH KEY EXISTS. The API rejects unknown
@@ -227,12 +273,27 @@ export function buildSearchRequest(p) {
227
273
  // serializes away anyway, but writing it conditionally is what makes the owner-only shape legible here
228
274
  // rather than an accident of JSON.stringify.
229
275
  const body = {};
230
- if (String(p.query ?? "").trim()) body.query = p.query;
276
+ // ── `q` CARRIES ONE TERM, AND AN ARRAY HERE WOULD BE A DIFFERENT SEARCH ────────────────────────
277
+ //
278
+ // A scalar `q` is a RANKED query: the similarity channels below apply to it. A LIST in the same field
279
+ // is not a wider ranked query — it is an exact-text filter, and the register refuses to combine it with
280
+ // similarity at all ("a list is an exact-text filter, not a ranked query"). Measured: the ranked form
281
+ // returns a live mark the register itself tiers `identical` via its lookalike channel, and the list form
282
+ // does not, because that mark's text does not contain the term. So an array reaching this line would
283
+ // silently drop look-alike coverage while answering 200 — a narrowed query wearing a complete answer,
284
+ // the failure this connector already refuses a multi-term stack to prevent.
285
+ //
286
+ // It is refused rather than joined or first-element-picked, for the same reason the stack is.
287
+ if (Array.isArray(p.query))
288
+ throw new Error("[signa] `query` reached the request builder as a list. A list in `q` is an exact-text "
289
+ + "filter on this register and cannot carry the similarity channels a ranked band asks for, so it "
290
+ + "would drop look-alike matches while answering 200. Send one term per request.");
291
+ if (String(p.query ?? "").trim()) body.q = p.query;
231
292
  const match = typeof p.match === "string" ? p.match.trim() : "";
232
293
  if (match && DETERMINISTIC_MATCH.has(match)) {
233
- body.match = match; // sending strategies alongside is a 4xx, not a preference
294
+ body.match = match; // sending similarity alongside is a 4xx, not a preference
234
295
  } else {
235
- body.strategies = Array.isArray(p.strategies) && p.strategies.length ? p.strategies : ["exact"];
296
+ body.similarity = similarityFor(p.strategies);
236
297
  }
237
298
  const filters = buildFilters(p);
238
299
  if (Object.keys(filters).length) body.filters = filters;
@@ -319,7 +380,14 @@ export function normalizeRecord(rec, officeHint = null) {
319
380
  registrationNumber: rec.registration_number ?? rec.ir_number ?? null,
320
381
  applicationDate: toIso(rec.filing_date),
321
382
  registrationDate: toIso(rec.registration_date),
322
- expiryDate: toIso(rec.expiry_date),
383
+ // — THE OFFICE'S DATE FIRST, THEN SIGNA'S OWN (their 27 September release). That release split what
384
+ // an office published from what Signa computes: `expiry_date` now carries only the published date and
385
+ // is EMPTY on USPTO records, where the computed one moved to `derived.expiry_date`. Read one and the
386
+ // field goes quietly null on every US record — measured on the record bodies of the run that
387
+ // straddled the release: 4 of 9 US records carried it only under `derived`, and all 4 normalised to
388
+ // null here. Where both are present they are identical (12 of 12 on that run), so the office's value
389
+ // first costs nothing and keeps the published date authoritative when there is one.
390
+ expiryDate: toIso(rec.expiry_date ?? rec.derived?.expiry_date),
323
391
  statusClass: statusClassOf(rec), // live | dead | unknown — authoritative for the gates
324
392
  statusText: pickStatusText(rec),
325
393
  markText: rec.mark_text ?? null,
@@ -353,16 +421,31 @@ export function normalizeRecord(rec, officeHint = null) {
353
421
  // "absent from the register", and `nativeScriptIndex` is settled from the record, not from this.
354
422
  markTextScript: rec.mark_text_script ?? null,
355
423
  markTextLanguage: rec.mark_text_language ?? null,
356
- renewalDueDate: toIso(rec.renewal_due_date),
424
+ // The same split as `expiryDate` above, and the vendor names both fields in it. UNEXERCISED ON THE
425
+ // DATA WE HOLD: of the 22 record bodies from the run that straddled the release, none carried a
426
+ // renewal date in either place, so the fallback recovers nothing we can point at. It is here because
427
+ // the register says this field moved with expiry, and reading one place is how the expiry loss
428
+ // happened; the arm for it is therefore synthetic and says so.
429
+ renewalDueDate: toIso(rec.renewal_due_date ?? rec.derived?.renewal_due_date),
357
430
  publicationDate: toIso(rec.publication_date),
358
431
  priorityDate: toIso(rec.priority_date),
359
432
  terminationDate: toIso(rec.termination_date),
360
- // Opposition data IS on the record now: `opposition_window` rides every search row and
361
- // `proceedings_count` the full record. The per-proceeding detail behind
362
- // GET /v1/trademarks/{id}/proceedings is still an unwired tool — so the window and the count are
363
- // reported, and the absence of detail is stated rather than left to look like an absence of
364
- // proceedings.
365
- oppositionWindow: rec.opposition_window ?? null,
433
+ // Opposition data IS on the record, and NOT where this used to look. The register reports the window
434
+ // under its computed object, and as an OBJECT rather than a scalar:
435
+ // `{supported, window_opens, window_closes, …}`, or `{supported: false, reason, rule_id}` where the
436
+ // stage cannot be opposed. `proceedings_count` is still the office's own, top-level.
437
+ //
438
+ // THE OLD READ RETURNED NULL ON EVERY RECORD WE HOLD, and the comment here asserted otherwise — that
439
+ // the window "rides every search row". Measured over all four saved runs on this register: 0 of 6,
440
+ // 0 of 5, 0 of 7 and 0 of 22 bodies carried it top-level, and 6, 3, 7 and 22 carried it under the
441
+ // computed object. So the earliest evidence we have already reads the new place, eleven days before
442
+ // the release the register attributes the move to. Which of the two dates is right does not change
443
+ // what to read; it does mean this was not reported missing for as long as it was missing.
444
+ //
445
+ // The per-proceeding detail behind GET /v1/trademarks/{id}/proceedings is still an unwired tool, so
446
+ // the window and the count are reported and the absence of detail is stated rather than left to look
447
+ // like an absence of proceedings.
448
+ oppositionWindow: rec.opposition_window ?? rec.derived?.opposition_window ?? null,
366
449
  proceedingsCount: rec.proceedings_count ?? null,
367
450
  oppositions: null,
368
451
  _provenance: { opposition: rec.proceedings_count != null ? `count=${rec.proceedings_count} from the record; per-proceeding detail via /proceedings, tool not wired` : "per-proceeding detail via /proceedings, tool not wired" },
@@ -466,6 +549,30 @@ export function normalizeSearchResponse(body, echoQuery) {
466
549
  strategies_used: meta.strategies_used ?? [],
467
550
  match: meta.match ?? null,
468
551
  search_id: meta.search_id ?? null,
552
+ // ── THE REGISTER'S OWN WARNINGS, CARRIED WHOLE ──────────────────────────────────────────────
553
+ //
554
+ // Passed through as the register sent them rather than filtered to the codes we happen to know. A
555
+ // recorder keyed to a fixed list of codes looks like coverage and is an empty column the day the
556
+ // register adds one, and there is no way to tell those two apart from the outside. An empty array is
557
+ // the ordinary case: a clean response carries no `warnings` key at all.
558
+ //
559
+ // TWO CODES ARE KNOWN TO ARRIVE HERE, and they behave oppositely — which is the reason to keep the
560
+ // list whole rather than reason about either one.
561
+ //
562
+ // `mixed_script` is emitted on the exact-text filter path and NOT on the ranked path, because a
563
+ // ranked query folds look-alike letters through its own similarity channel and so has nothing to
564
+ // warn about. Every sweep this connector sends is ranked, so this code will not appear, and an
565
+ // empty list is NOT evidence that a query carried no mixed-script risk. Whatever guards that on the
566
+ // ranked path still has to.
567
+ //
568
+ // `expanded_fallback` DOES arrive on requests this connector sends. Measured: it fires on
569
+ // `offices` together with `nice_classes`, and on `goods_services_text`, which rides every
570
+ // goods-narrowed question. It says the grouped view cannot serve that filter, so the answer comes
571
+ // back one row per RECORD instead of one row per MARK — and the register's own note says that
572
+ // inflates the total. The total is what the enumerate ceiling reads to call a band a crowd, so a
573
+ // band can be declared a crowd on a number that counts designations rather than marks. This field
574
+ // is what makes that visible; it does not yet make it safe.
575
+ warnings: Array.isArray(meta.warnings) ? meta.warnings : [],
469
576
  // The corpus total when the vendor counted it exactly; null when it did not answer, when the
470
577
  // total was not requested, and when the figure it returned is an approximation. NEVER the page
471
578
  // size — `data.length` is `count`, and conflating the two is how a page reads as a corpus.
@@ -485,8 +592,8 @@ export function normalizeSearchResponse(body, echoQuery) {
485
592
  //
486
593
  // THIS HEADER USED TO ADVERTISE AN ENVIRONMENT VARIABLE, `CLAWDI_SIGNA_MOCK`. There was never such a
487
594
  // variable: two occurrences in the whole tree, both comments, zero reads. Following the line produced a
488
- // run that died at preflight in seconds, and it cost a real investigation (, found by e2e looking
489
- // for a credential-free register lane for fault injection).
595
+ // run that died at preflight in seconds, and it cost a real investigation — found while looking for a
596
+ // register path that needs no credential, to inject faults into.
490
597
  //
491
598
  // The lane below is real and works. What does not exist is a way to REACH it from a run: `doSearch`,
492
599
  // `doRecordFetch` and `doEnumerate` each take `{ mock = false }`, the capability wrappers pass
@@ -494,10 +601,11 @@ export function normalizeSearchResponse(body, echoQuery) {
494
601
  // (driver.config.mjs) call these functions with no options object at all. So the only callers that
495
602
  // enable it are this provider's own tests, and that is the whole of it.
496
603
  //
497
- // AND IT STAYS THAT WAY DELIBERATELY, rather than for want of wiring. offered to wire it; the
498
- // acceptance that comes with wiring is "preflightCredentials must not demand SIGNA_API_KEY when the
499
- // mock lane is on", and that produces a clearance run with no register credential, reaching the
500
- // register stage, answering from ten in-repo fixtures. ADR-0003's first table row rules exactly that
604
+ // AND IT STAYS THAT WAY DELIBERATELY, rather than for want of wiring. Wiring it was offered and
605
+ // declined; the acceptance that comes with wiring is "preflightCredentials must not demand
606
+ // SIGNA_API_KEY when the mock path is on", and that produces a clearance run with no register
607
+ // credential, reaching the register stage, answering from ten in-repo fixtures. ADR-0003's first
608
+ // table row rules exactly that
501
609
  // out: the register REFUSES at preflight, by name, because "an unconfigured register that answered
502
610
  // 'no conflicts found' is the most dangerous output this system can produce".
503
611
  //
@@ -163,10 +163,18 @@ export const NOT_COUNTED_EVENTS = {
163
163
  "jx-aim-consumed", "jx-candidate-fold", "jx-nativeread",
164
164
  "jx-serp-grid", "jx-serp-grid-overflow", "jx-serp-grid-spec", "jx-slices-stated",
165
165
  "knockout-published", "knockout-receipts",
166
- "knockout-register-records", "knockout-sweep-skipped", "knockout-sweep-start",
167
- // The sweep's cost line: marks, calls, cells, the largest place list any one name carried, the minutes
168
- // and the target they are read against. A RECORD, never a failure — nothing about it is a step that
169
- // went wrong, a question re-asked or an attempt repeated, so it does not bear on "first time" (515).
166
+ "knockout-register-records",
167
+ // The run's own span against the ten-minute target, written at delivery because that is the first
168
+ // point at which the run's span is known. Same class as the sweep's line below: a RECORD, never a
169
+ // failure. `overBar: null` on it means the span could not be measured, which is a gap in the record
170
+ // and still not a retry (515).
171
+ "knockout-run-total",
172
+ "knockout-sweep-skipped", "knockout-sweep-start",
173
+ // The sweep's cost line: marks, calls, cells, the largest place list any one name carried, and the
174
+ // sweep's own elapsed minutes. A RECORD, never a failure — nothing about it is a step that went
175
+ // wrong, a question re-asked or an attempt repeated, so it does not bear on "first time" (515).
176
+ // The target moved off this line to the run's own, above: the bar is about a run and this span is
177
+ // one stage of it.
170
178
  "knockout-sweep-total", "level-scope-note",
171
179
  "named-band-merged", "one-shot-stamp-settled", "order-probe", "output-snapshot", "owner-screen-derived",
172
180
  "placement-borderline", "placement-form-written", "plan-execution", "plan-execution-census",
@@ -282,17 +282,40 @@ export function narrowingNamesItsCrowd(a, runDir) {
282
282
  const exec = readJson(driverDir(runDir, "plan-execution.json"));
283
283
  if (!exec) return { ok: false, saw: "_driver/plan-execution.json absent, so no question's count can be read" };
284
284
  const counts = executedCounts(exec);
285
- const crowdMissing = [], crowdUncounted = [], narrowingUncounted = [];
285
+ // A QUESTION ASKED OFF THE PLAN IS DISPOSITIONED, NOT OMITTED, and this op had no branch for it.
286
+ //
287
+ // The plan record carries an `unplanned` bucket: the engine asked something the plan did not contain
288
+ // and said so. Such an entry is on the record with a disposition and carries no executed count, and
289
+ // this check used to render that as "carries no count of its own" — which describes a silent omission.
290
+ // On the run that surfaced it, that ONE narrowing was the whole failure: 35 of 36 carried their own
291
+ // count, all 14 crowds were on the record, and neither crowd branch fired.
292
+ //
293
+ // IT IS NAMED, NOT COUNTED AGAINST THE ENGINE. The rule this op states is that the crowd a narrowing
294
+ // replaced is on the record with a count. An off-plan question satisfies the first half and cannot
295
+ // satisfy the second, because no count exists to read — and whether the engine ought to record one for
296
+ // a question it asked off-plan is a question about the RECORD, not about this narrowing. That belongs
297
+ // to the lane that writes the record; this op says what it found and declines to invent a verdict.
298
+ const offPlanQids = new Set(list(exec.unplanned).map((e) => text(e?.qid)).filter(Boolean));
299
+ const crowdMissing = [], crowdUncounted = [], narrowingUncounted = [], narrowingOffPlan = [];
286
300
  for (const n of narrowings) {
287
301
  const crowd = text(n.narrows);
288
302
  if (!byQid.has(crowd)) crowdMissing.push(`${n.qid} → ${crowd}`);
289
303
  else if (!counts.has(crowd)) crowdUncounted.push(`${n.qid} → ${crowd}`);
290
- if (!counts.has(text(n.qid))) narrowingUncounted.push(text(n.qid));
304
+ if (!counts.has(text(n.qid))) {
305
+ if (offPlanQids.has(text(n.qid))) narrowingOffPlan.push(text(n.qid));
306
+ else narrowingUncounted.push(text(n.qid));
307
+ }
291
308
  }
292
309
  const parts = [`${plural(narrowings.length, "narrowing")} naming ${new Set(narrowings.map((n) => text(n.narrows))).size} crowd(s)`];
293
310
  if (crowdMissing.length) parts.push(`${crowdMissing.length} name a crowd that is not on the record${sample(crowdMissing)}`);
294
311
  if (crowdUncounted.length) parts.push(`${crowdUncounted.length} name a crowd with no count${sample(crowdUncounted)}`);
295
312
  if (narrowingUncounted.length) parts.push(`${narrowingUncounted.length} carry no count of their own${sample(narrowingUncounted)}`);
313
+ // Said whether or not anything failed, because it is the difference between a question the engine
314
+ // never answered and one it asked outside the plan — and a reader cannot tell those apart from a
315
+ // count's absence alone.
316
+ if (narrowingOffPlan.length) parts.push(`${narrowingOffPlan.length} asked OFF THE PLAN: on the record`
317
+ + ` in plan-execution's \`unplanned\` bucket with a disposition and no executed count, so this check`
318
+ + ` has no count to read rather than a count that is missing${sample(narrowingOffPlan)}`);
296
319
  if (parts.length === 1) parts.push("each crowd and each narrowing on the record with its count");
297
320
  return { ok: !crowdMissing.length && !crowdUncounted.length && !narrowingUncounted.length, saw: parts.join("; ") };
298
321
  }