clearotron 0.4.1 → 0.5.0-beta.1

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 (104) hide show
  1. package/README.md +1 -0
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/bin/start.mjs +33 -8
  4. package/build-info.json +2 -2
  5. package/driver/CHANGELOG.md +32 -0
  6. package/driver/ask-ledger.mjs +90 -16
  7. package/driver/band-shape.mjs +65 -11
  8. package/driver/band-size.mjs +10 -0
  9. package/driver/close-verify.mjs +29 -2
  10. package/driver/common-law-coverage-status.mjs +1 -1
  11. package/driver/connotation-search.mjs +7 -9
  12. package/driver/contract-e3-backlog.mjs +34 -34
  13. package/driver/contract-e3-baseline.json +5 -5
  14. package/driver/contract-vocabulary.mjs +12 -12
  15. package/driver/corrections-delivered.mjs +113 -0
  16. package/driver/corrections-feedforward.mjs +16 -6
  17. package/driver/corrective-arm.mjs +11 -8
  18. package/driver/coverage-form-io.mjs +2 -1
  19. package/driver/coverage-form.mjs +17 -2
  20. package/driver/coverage-union.mjs +11 -2
  21. package/driver/digest-queue.mjs +1 -1
  22. package/driver/disposition-call.mjs +1 -1
  23. package/driver/document-coverage.mjs +3 -2
  24. package/driver/effective-scope.mjs +12 -7
  25. package/driver/engine/mcp/band-server.mjs +77 -13
  26. package/driver/engine/mcp/dispositions-server.mjs +2 -2
  27. package/driver/engine/mcp/held-answers.mjs +128 -0
  28. package/driver/engine/mcp/recording-server.mjs +10 -6
  29. package/driver/engine/mcp/supplemental.mjs +74 -12
  30. package/driver/findings-model.mjs +16 -4
  31. package/driver/floor-duty.mjs +9 -0
  32. package/driver/gate-metrics.mjs +7 -1
  33. package/driver/gateway.mjs +28 -14
  34. package/driver/goods-evidence.mjs +295 -0
  35. package/driver/knockout-assess-record.mjs +1 -1
  36. package/driver/narrative-refutation-record.mjs +40 -2
  37. package/driver/package.json +1 -1
  38. package/driver/pipeline.mjs +1994 -323
  39. package/driver/placement-decisions.mjs +243 -0
  40. package/driver/placement-form.mjs +42 -8
  41. package/driver/placement-union.mjs +14 -6
  42. package/driver/provider-usage.mjs +10 -7
  43. package/driver/publish/audit-from-spine.mjs +5 -2
  44. package/driver/publish/index.mjs +19 -4
  45. package/driver/publish/xlsx.mjs +6 -6
  46. package/driver/recall-reconciliation.mjs +15 -5
  47. package/driver/record-carry.mjs +70 -6
  48. package/driver/record-discard.mjs +7 -1
  49. package/driver/record-fold.mjs +14 -0
  50. package/driver/record-goods.mjs +101 -5
  51. package/driver/register-plan.mjs +221 -6
  52. package/driver/registry-fidelity.mjs +34 -3
  53. package/driver/remedy-accounting.mjs +52 -14
  54. package/driver/repair-composers.mjs +265 -105
  55. package/driver/repair-scope.mjs +327 -0
  56. package/driver/repair-targets.mjs +92 -0
  57. package/driver/result-noun-fields.mjs +3 -3
  58. package/driver/scope-facts.mjs +6 -2
  59. package/driver/screen-gate.mjs +96 -0
  60. package/driver/skills/clearance-common-law/SKILL.md +8 -0
  61. package/driver/skills/clearance-register/SKILL.md +29 -21
  62. package/driver/skills/clearance-register/digest.md +22 -17
  63. package/driver/skills/clearance-register/providers/clarivate.md +4 -4
  64. package/driver/skills/clearance-register/providers/corsearch.md +1 -1
  65. package/driver/skills/clearance-register/providers/euipo.md +4 -4
  66. package/driver/skills/clearance-register/providers/free-tier.md +4 -4
  67. package/driver/skills/clearance-register/providers/uspto-local.md +3 -3
  68. package/driver/skills/clearance-register/register-recipes.md +6 -6
  69. package/driver/skills/clearance-register/unit.md +29 -22
  70. package/driver/skills/clearance-search/delivery-contract.md +2 -58
  71. package/driver/skills/clearance-search/report-prose.md +12 -3
  72. package/driver/skills/clearance-search/risk-framework-triage.md +1 -1
  73. package/driver/skills/clearance-search/synthesis-rules.md +10 -13
  74. package/driver/skills/clearance-variants/SKILL.md +4 -4
  75. package/driver/skills/knockout-assess/SKILL.md +9 -14
  76. package/driver/skills/knockout-frame/SKILL.md +5 -5
  77. package/driver/skills/narrative-refutation/SKILL.md +1 -1
  78. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  79. package/driver/skills/whatif-memo/SKILL.md +1 -1
  80. package/driver/stage-context.mjs +2 -1
  81. package/driver/stages-knockout.mjs +1 -1
  82. package/driver/stages.mjs +96 -61
  83. package/driver/suite-census.json +415 -25
  84. package/driver/synthesis-record.mjs +310 -8
  85. package/driver/terminal-clamp.mjs +53 -2
  86. package/driver/verify-knockout.mjs +22 -4
  87. package/driver/verify.mjs +23 -3
  88. package/mcp-server/CHANGELOG.md +8 -0
  89. package/mcp-server/lib/driver.mjs +5 -0
  90. package/mcp-server/lib/events.mjs +25 -4
  91. package/mcp-server/lib/findings.mjs +4 -2
  92. package/mcp-server/package.json +1 -1
  93. package/package.json +1 -1
  94. package/portal-ui/package.json +1 -1
  95. package/providers/_shared/enumerate.mjs +12 -6
  96. package/providers/_shared/execute-plan.mjs +25 -1
  97. package/providers/_shared/follow-up-searches.mjs +139 -0
  98. package/providers/jx/src/core.js +3 -4
  99. package/providers/jx/src/judge.js +2 -2
  100. package/providers/jx/src/nativeread.js +1 -2
  101. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  102. package/providers/oauth-mcp-bridge/package.json +1 -1
  103. package/scripts/e2e-first-time.mjs +30 -13
  104. package/scripts/release-await-cut.mjs +4 -2
@@ -702,6 +702,61 @@ export function mintRecordCarryDoubts(artifact, { max = 25 } = {}) {
702
702
  return { doubts, minted: doubts.length, omitted: Math.max(0, all.length - doubts.length) };
703
703
  }
704
704
 
705
+ /**
706
+ * THE RECORD-CARRY DOUBTS AGAIN, FROM A TRACE WRITTEN AGAIN after a repair. A doubt of this family names one record
707
+ * under a POSITIONAL id, and the closure step's verdicts are applied by id, so a closure recorded against an id must
708
+ * never move onto another record:
709
+ * - a record still unreasoned keeps its doubt exactly as it stands: id, status and ending;
710
+ * - a record newly unreasoned gets a new doubt, under an id past every one this family gave, stitched by `stitch` as
711
+ * a fresh doubt is (in code; nothing is dispatched);
712
+ * - a record the new trace POSITIVELY reasons, a row for it that is not unreasoned, loses its doubt;
713
+ * - a record the new trace does not list at all keeps its doubt as it stands: an absence is not a ground.
714
+ * A trace that is missing or could not be computed (no readable band, among other causes) says nothing about any
715
+ * record, so it removes nothing and adds nothing: every doubt stands as the pass raised it (`unavailable: true`).
716
+ * Every other family's doubts stay exactly where they stood. The cap (`max`) limits only the new doubts: a record that
717
+ * keeps its doubt keeps it wherever the new trace lists it, or where it does not. Returns { doubts, fresh, dropped,
718
+ * minted, omitted, unavailable }: the ids of the new and the dropped doubts, the family's size, the records without a
719
+ * ground the cap left out, and whether the trace could be read at all. PURE.
720
+ */
721
+ export function recordCarryDoubtsAgain(doubts, trace, { stitch = (ds) => ds, max = 25 } = {}) {
722
+ const FAMILY = /^doubt:record-carry:unreasoned:(\d+)$/;
723
+ const all = Array.isArray(doubts) ? doubts : [];
724
+ const inFamily = (d) => FAMILY.test(String(d?.id ?? ""));
725
+ const uriOf = (d) => String(d?.subject?.uris?.[0] ?? "").toLowerCase();
726
+ const old = all.filter(inFamily);
727
+ if (trace?.computable !== true) return { doubts: all, fresh: [], dropped: [], minted: old.length, omitted: 0, unavailable: true };
728
+ const oldByUri = new Map(old.filter((d) => uriOf(d)).map((d) => [uriOf(d), d]));
729
+ // Minted whole, then capped: a record that keeps its doubt keeps it wherever the new trace lists it, and the cap
730
+ // limits only the new doubts, filled in the trace's order up to what the kept ones leave.
731
+ const mint = mintRecordCarryDoubts(trace, { max: Infinity });
732
+ const unreasonedUris = new Set(mint.doubts.map(uriOf));
733
+ const listed = new Set((Array.isArray(trace?.rows) ? trace.rows : []).map((r) => String(r?.uri ?? "").toLowerCase()).filter(Boolean));
734
+ const reasoned = (uri) => listed.has(uri) && !unreasonedUris.has(uri);
735
+ const carried = old.filter((d) => !unreasonedUris.has(uriOf(d)) && !reasoned(uriOf(d))); // not listed: kept as it stands
736
+ const keptCount = mint.doubts.filter((d) => oldByUri.has(uriOf(d))).length + carried.length;
737
+ let room = Math.max(0, max - keptCount);
738
+ let next = old.reduce((n, d) => Math.max(n, Number(FAMILY.exec(d.id)[1])), 0);
739
+ const family = [], fresh = [];
740
+ for (const d of mint.doubts) {
741
+ const prior = oldByUri.get(uriOf(d));
742
+ if (prior) family.push(prior);
743
+ else if (room > 0) { room--; const f = { ...d, id: `doubt:record-carry:unreasoned:${++next}` }; fresh.push(f); family.push(f); }
744
+ }
745
+ family.push(...carried);
746
+ const stitched = fresh.length ? new Map(stitch(fresh).map((d) => [d.id, d])) : new Map();
747
+ const members = family.map((d) => stitched.get(d.id) ?? d);
748
+ const keptIds = new Set(members.map((d) => d.id));
749
+ const out = [];
750
+ let placed = false;
751
+ for (const d of all) {
752
+ if (!inFamily(d)) { out.push(d); continue; }
753
+ if (!placed) { out.push(...members); placed = true; }
754
+ }
755
+ if (!placed) out.push(...members);
756
+ return { doubts: out, fresh: fresh.map((d) => d.id), dropped: old.filter((d) => !keptIds.has(d.id)).map((d) => d.id),
757
+ minted: members.length, omitted: Math.max(0, mint.doubts.length - (members.length - carried.length)), unavailable: false };
758
+ }
759
+
705
760
  /**
706
761
  * The one-line reader answer to "where did this record stop, and why". Takes the artifact and a mark
707
762
  * text or uri; returns the matching rows. The acceptance question for — "does it say why 澜珀 did
@@ -915,12 +970,7 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
915
970
  // So the carry rows are the population — they are where a stated drop is RECORDED — and the
916
971
  // reconciliation is demoted to optional corroboration. Measured on that delivery, the correct
917
972
  // population is 70 rows over 34 distinct marks, all `digest:reasoned-negative`, and it contains both.
918
- const arrived = new Set(["finding", "findings-surface"]);
919
- const population = carryRows.filter((r) => r?.uri
920
- && r.reason_source === "step-stated" // the sibling owns step-silent; step-structural is mechanical
921
- && !arrived.has(r.reach)); // arrived, or arrived somewhere visible, is not a divergence
922
-
923
- // Kept for corroboration only. `ended` no longer gates anything; where the reconciliation DOES name a
973
+ // Kept for corroboration. `ended` gates nothing but one exclusion below; where the reconciliation DOES name a
924
974
  // position it agrees with, that is recorded on the row so a reader can weigh it.
925
975
  const ended = [];
926
976
  for (const bucket of ["top_slice", "residual"]) {
@@ -930,6 +980,20 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
930
980
  }
931
981
  }
932
982
  const endedUris = new Set(ended.map((e) => e.uri));
983
+ const arrived = new Set(["finding", "findings-surface"]);
984
+ // PLACEMENT'S OWN SET-ASIDE IS NOT A LOST FINDING. A record placement set aside, by name or by its group's row,
985
+ // leaves the placement seam with the step's ground before the digest ever sees it: the ruled method's decision,
986
+ // recorded where it was made. Read as a stated drop it was listed as a divergence pointing at another artifact,
987
+ // and on a crowded band those rows buried the few this check is for. It stays in the population where the digest
988
+ // ended its position as a finding, because then the record did vanish: in the reconciliation, or in the finding
989
+ // rows the digest's own typed calls recorded, which the reconciliation's silence cannot erase.
990
+ const digestFound = new Set((Array.isArray(digestFindingUris) ? digestFindingUris : []).map(lc));
991
+ const placementSetAside = (r) => r.stopped_at === "placement" && r.reason === "placement:set-aside"
992
+ && !endedUris.has(lc(r.uri)) && !digestFound.has(lc(r.uri));
993
+ const population = carryRows.filter((r) => r?.uri
994
+ && r.reason_source === "step-stated" // the sibling owns step-silent; step-structural is mechanical
995
+ && !arrived.has(r.reach) // arrived, or arrived somewhere visible, is not a divergence
996
+ && !placementSetAside(r));
933
997
  const byUri = new Map();
934
998
  for (const r of carryRows) if (r?.uri) byUri.set(lc(r.uri), r);
935
999
 
@@ -92,8 +92,13 @@ const clip = (s, n = 300) => {
92
92
  * not name cannot be distinguished between considered-and-not-selected and never reached at all. Those
93
93
  * rows carry `verdict: "discarded"` with `reason_source: "step-structural"` and a reason naming the
94
94
  * incompletion, so the fact survives; they are never silently folded into ordinary drops.
95
+ *
96
+ * A SKIPPED PASS DECIDED NOTHING. A resumed run skips a stage whose answer is on disk, and that answer is an
97
+ * earlier pass's. Where `prior` (the folded ledger) already holds that earlier pass's row for a record at this
98
+ * seam, the skipped pass writes none: under the winner rule an incompletion written over it erased every ground
99
+ * the step had given. A record no earlier pass spoke about is still recorded as not reached.
95
100
  */
96
- export function seamRows({ seam, stage, trigger = null, pass = 1, completed = true,
101
+ export function seamRows({ seam, stage, trigger = null, pass = 1, completed = true, skipped = false, prior = null,
97
102
  saw = [], carried = [], reasonFor = null, evidence = "" } = {}) {
98
103
  if (!DISCARD_SEAMS.includes(seam)) return [];
99
104
  const carriedSet = new Set();
@@ -112,6 +117,7 @@ export function seamRows({ seam, stage, trigger = null, pass = 1, completed = tr
112
117
  continue;
113
118
  }
114
119
  if (!completed) {
120
+ if (skipped && prior?.byUri?.get?.(uri)?.[seam]) continue;
115
121
  rows.push({
116
122
  seam, stage, trigger, pass, uri, verdict: "discarded",
117
123
  reason: `${seam}:stage-incomplete`, reason_source: "step-structural",
@@ -153,6 +153,20 @@ export function modelRecordPath(runDir, findingsPath) {
153
153
  return readFold(runDir)?.map && existsSync(at) ? at : findingsPath;
154
154
  }
155
155
 
156
+ /**
157
+ * The model's ordinal behind a finding of findings.json as it stands, given the fold state `readFold` returns.
158
+ * With no fold in force the file is the model's record, so the ordinal is its own. Under a fold a folded finding
159
+ * IS the object of the member the fold kept, so that member is the answer. Null when a fold is in force and its
160
+ * map does not name the finding: a caller then leaves it alone rather than guessing. PURE.
161
+ */
162
+ export function keptModelOrdinal(state, ordinal) {
163
+ if (!state?.map) return ordinal;
164
+ const members = state.map.foldedToModel?.[String(ordinal)];
165
+ if (!Array.isArray(members) || !members.length) return null;
166
+ if (members.length === 1) return members[0];
167
+ return (state.merges ?? []).find((m) => members.includes(m?.kept))?.kept ?? null;
168
+ }
169
+
156
170
  /** The fold as it stands, to put back if a pass that changed it is rolled back. Never throws. */
157
171
  export function snapshotFold(runDir) {
158
172
  const state = readFold(runDir);
@@ -77,12 +77,25 @@ export function recordGoods(body, { english = true } = {}) {
77
77
  }
78
78
  }
79
79
  if (out.length === 0 && Array.isArray(b?._raw?.classifications)) {
80
- for (const c of b._raw.classifications) push([c?.nice_class], c?.goods_services_text, null, isEnglish(c?.goods_services_language) || (!tagged(c?.goods_services_language) && english));
80
+ for (const c of b._raw.classifications) {
81
+ const at = out.length;
82
+ push([c?.nice_class], c?.goods_services_text, null, isEnglish(c?.goods_services_language) || (!tagged(c?.goods_services_language) && english));
83
+ // The register's own flag, on the full record it serves (Signa, Retrieve Trademark, the classification
84
+ // object's `goods_services_text_truncated`): true is a cut, false is whole, and no flag establishes nothing.
85
+ if (out.length > at) {
86
+ if (c?.goods_services_text_truncated === true) out[at].cut = true;
87
+ else if (c?.goods_services_text_truncated !== false) out[at].unknown = true;
88
+ }
89
+ }
81
90
  }
82
91
  if (out.length === 0 && b.goodsAndServices != null) {
83
92
  const gs = b.goodsAndServices;
93
+ // One string is the register's own statement as this install's US index holds it, whole (uspto-local). A list
94
+ // is a register whose full-record operation this tree documents nowhere (EUIPO and the free tier): its goods
95
+ // are read, and their completeness is not established.
84
96
  if (typeof gs === "string") push([], gs, null, english);
85
97
  else if (Array.isArray(gs)) {
98
+ const from = out.length;
86
99
  for (const g of gs) {
87
100
  if (typeof g === "string") { push([], g, null, english); continue; }
88
101
  const d = g?.description;
@@ -91,11 +104,35 @@ export function recordGoods(body, { english = true } = {}) {
91
104
  push([g?.classNumber ?? g?.class ?? g?.niceClass ?? g?.nice_class], textOf(chosen ?? g?.text ?? g?.goods), null,
92
105
  Boolean(en) || (Array.isArray(d) ? !tagged(d[0]?.language) && english : english));
93
106
  }
107
+ for (let i = from; i < out.length; i++) out[i].unknown = true;
94
108
  }
95
109
  }
96
110
  return out;
97
111
  }
98
112
 
113
+ /**
114
+ * WHAT A HELD RECORD'S GOODS REST ON: the operation its body came from, and whether that body supplies the goods
115
+ * field at all. PURE. `operation` is read off the body's shape, as `recordGoods` reads it:
116
+ * · `record-text` — Clarivate's record text, the full record (its statements are whole: the accepted provenance);
117
+ * · `retrieve` — Signa's Retrieve Trademark, the full record, each statement with the register's own flag;
118
+ * · `bulk-register` — the register's own statement as this install's US index holds it, whole;
119
+ * · `undocumented` — any other shape: its completeness is not established.
120
+ * `field` is `listed` (the field is there with goods), `empty` (there, listing none) or `none` (not there at
121
+ * all): a body whose goods field is missing, or that will not parse, is not one that lists no goods.
122
+ */
123
+ export function recordGoodsProvenance(body) {
124
+ const b = Array.isArray(body) ? body[0] : body;
125
+ if (!b || typeof b !== "object") return { operation: "undocumented", field: "none" };
126
+ const said = (v) => String(textOf(v) ?? "").trim() !== "";
127
+ if (Array.isArray(b.goodsServices))
128
+ return { operation: "record-text", field: b.goodsServices.some((g) => said(g?.description) || said(g?.translation)) ? "listed" : "empty" };
129
+ if (Array.isArray(b?._raw?.classifications))
130
+ return { operation: "retrieve", field: b._raw.classifications.some((c) => said(c?.goods_services_text)) ? "listed" : "empty" };
131
+ if (typeof b.goodsAndServices === "string") return { operation: "bulk-register", field: b.goodsAndServices.trim() ? "listed" : "empty" };
132
+ if (b.goodsAndServices != null) return { operation: "undocumented", field: recordGoods(b).length ? "listed" : "empty" };
133
+ return { operation: "undocumented", field: "none" };
134
+ }
135
+
99
136
  // THE RAW GOODS LIST SAYS NOTHING THE NORMALISED ONE DOES NOT. A Clarivate record holds its goods twice: as the
100
137
  // register wrote them (`_raw.goodsServices.intClassDescriptions`) and as the provider maps them, item for item,
101
138
  // into `goodsServices` beside it (providers/clarivate/src/core.js `normalizeGoodsServices`). On a crowded test
@@ -163,6 +200,59 @@ export function bandGoodsIndex(runDir) {
163
200
  /** The goods of one band row from an index, or null when the run holds none for it. PURE. */
164
201
  export const goodsFor = (index, record) => index?.get(normalizeRecordUri(record?.record_id)) ?? null;
165
202
 
203
+ /**
204
+ * The goods a band row holds itself, where the register's search answer carries them: Signa sends each record's
205
+ * class statements on the search row (`raw.classifications`) and flags a statement it cut. Each statement is read as
206
+ * a held body's is (`recordGoods`) and marked `cut: true` where the register cut it, so a statement that stops early
207
+ * is never read as the whole of its class. null when the row holds none. PURE.
208
+ *
209
+ * WHOLE ONLY WHERE THE ANSWER SAYS SO. The search answer our request asks for is the register's default, which it
210
+ * documents as "the first 280 characters" of each statement (docs.signa.so, Search Trademarks, "Response options",
211
+ * `include`: "`full_goods_services` returns the full goods and services text instead of the first 280
212
+ * characters"); the request never asks for the full text. So a statement shorter than 280 characters is whole, a
213
+ * flagged one is cut, and an unflagged one of 280 characters or more is marked `unknown: true`: its completeness is
214
+ * not established, and missing words in it establish nothing.
215
+ */
216
+ export const DEFAULT_ANSWER_CUT = 280;
217
+ export function rowGoods(row) {
218
+ const classes = Array.isArray(row?.raw?.classifications) ? row.raw.classifications : null;
219
+ if (!classes) return null;
220
+ const english = untaggedIsEnglish(normalizeRecordUri(row?.record_id));
221
+ const out = [];
222
+ for (const c of classes) {
223
+ const [g] = recordGoods({ _raw: { classifications: [c] } }, { english });
224
+ if (!g) continue;
225
+ const { cut: _cut, unknown: _unknown, ...whole } = g; // the row's own rule below, not the full record's
226
+ if (c?.goods_services_text_truncated === true) out.push({ ...whole, cut: true });
227
+ else if ([...String(c?.goods_services_text ?? "")].length >= DEFAULT_ANSWER_CUT) out.push({ ...whole, unknown: true });
228
+ else out.push(whole);
229
+ }
230
+ return out.length ? out : null;
231
+ }
232
+
233
+ /**
234
+ * Was a statement of these goods cut short by the register? A record whose statements match no word, while one of
235
+ * them stops where the register cut it, is unknown to the goods test, not a mismatch: the words cut off may be the
236
+ * client's. Only goods read off a band row carry the mark (`rowGoods`); a held body's statements are whole. PURE.
237
+ */
238
+ export const statementCut = (goods) => (goods ?? []).some((g) => g?.cut === true);
239
+
240
+ /** Is a statement of these goods one whose completeness is not established (`rowGoods`, `recordGoods`)? PURE. */
241
+ export const completenessUnknown = (goods) => (goods ?? []).some((g) => g?.unknown === true);
242
+ /** Could words cut off, or not shown, from these goods be the client's? Then a statement matching none is unknown. PURE. */
243
+ const notWhole = (goods) => statementCut(goods) || completenessUnknown(goods);
244
+
245
+ /** How many records rest on each kind of goods evidence: a held body, a row's whole statements, a row's statements cut short or not established whole, or none. PURE. */
246
+ export function goodsEvidence(records, goods) {
247
+ const out = { held: 0, complete: 0, cut: 0, unknown: 0, none: 0 };
248
+ for (const r of Array.isArray(records) ? records : []) {
249
+ if (goods?.held?.(r)) { out.held++; continue; }
250
+ const g = goods ? goods.of(r) : null;
251
+ if (!g) out.none++; else if (statementCut(g)) out.cut++; else if (completenessUnknown(g)) out.unknown++; else out.complete++;
252
+ }
253
+ return out;
254
+ }
255
+
166
256
  const fold = (s) => foldDiacritics(String(s ?? "")).toLowerCase();
167
257
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
168
258
 
@@ -206,7 +296,7 @@ export function goodsWordCounter(words, { stems = false } = {}) {
206
296
  if (!res.length || !readable.length) return null;
207
297
  const text = fold(readable.map((g) => (g.alt ? `${g.text}\n${g.alt}` : g.text)).join("\n"));
208
298
  const n = res.filter((re) => re.test(text)).length;
209
- return n === 0 && classUnread(goods) ? null : n;
299
+ return n === 0 && (classUnread(goods) || notWhole(goods)) ? null : n;
210
300
  };
211
301
  }
212
302
 
@@ -238,7 +328,7 @@ export function goodsWordsMatched(words, { stems = false } = {}) {
238
328
  return (goods) => {
239
329
  if (!list.length || !(goods ?? []).some((g) => g.en !== false)) return null;
240
330
  const hit = tests.filter(([, t]) => t(goods) === 1).map(([w]) => w);
241
- return hit.length === 0 && classUnread(goods) ? null : hit;
331
+ return hit.length === 0 && (classUnread(goods) || notWhole(goods)) ? null : hit;
242
332
  };
243
333
  }
244
334
 
@@ -269,8 +359,12 @@ export function instructedOrder(P, job = null) {
269
359
  * search asked for beside the one the variants stage named. The client's markets come from the caller:
270
360
  * this module is loaded by the band tool server, which reads no account settings. IMPURE (reads the run).
271
361
  * Any part that cannot be read is simply absent, and the shape sorts without it.
362
+ *
363
+ * A record's goods are its held body's, and where the run holds no body, the statements its band row carries
364
+ * (`rowGoods`). `held` says which: a line's goods are "on file" only where a body is held, because that is the
365
+ * record `band_record` can open. `records` are the band's, so the shape tests goods whenever either is held.
272
366
  */
273
- export function bandShapeGoods(P) {
367
+ export function bandShapeGoods(P, records = []) {
274
368
  let model = null;
275
369
  try { if (existsSync(P.variantManifestModel)) model = parseVariantManifestModel(readFileSync(P.variantManifestModel, "utf8")); } catch { model = null; }
276
370
  let searched = "";
@@ -285,7 +379,9 @@ export function bandShapeGoods(P) {
285
379
  const words = product ? model.product_words : Array.isArray(model?.goods_words) ? model.goods_words : [];
286
380
  const stems = true; // the word list the shape groups on is matched as stems, whichever source it came from
287
381
  const hits = goodsWordCounter(words, { stems });
288
- const goods = index.size ? { of: (r) => goodsFor(index, r), hits, matched: goodsWordsMatched(words, { stems }) } : null;
382
+ const held = (r) => goodsFor(index, r);
383
+ const rowsHold = Array.isArray(records) && records.some((r) => r && typeof r === "object" && rowGoods(r));
384
+ const goods = index.size || rowsHold ? { of: (r) => held(r) ?? rowGoods(r), held: (r) => held(r) != null, hits, matched: goodsWordsMatched(words, { stems }) } : null;
289
385
  const goodsTest = { source: product ? "product_words" : words.length ? "goods_words" : "none", words };
290
386
  return { goods, goodsTest, goodsWordCount: words.length, dominantTerm: searched || manifestDominant, manifestDominant };
291
387
  }
@@ -59,6 +59,8 @@ import { normalizeTerritory } from "../providers/_shared/territory-codes.mjs";
59
59
  import { bindingLayersFor, layerCoverageFor } from "./binding-layers.mjs";
60
60
  import { entryTermIssues, goodsTermsList, stripGoodsReservedWords, hasAnchoredWildcard, termAnnotationIssue, termMarkupIssue, termShapeIssue, termSubstanceIssue } from "../providers/_shared/term-shape.mjs";
61
61
  import { AWAITS_READING_TURN, awaitsReadingTurn, guardParentQid } from "../providers/_shared/plan-guards.mjs";
62
+ import { asksIdenticalMark, identicalMarkTerms } from "../providers/_shared/follow-up-searches.mjs";
63
+ import { territoryKey } from "./territory-tiers.mjs";
62
64
  import { formKey, romanizationSpellings, isNonLatinTerm } from "../providers/_shared/script-form.mjs"; import { marketsFilingScriptOf } from "./registration-scripts.mjs";
63
65
 
64
66
  export const PLAN_SCHEMA_VERSION = 1;
@@ -2155,6 +2157,95 @@ export function foldSupplementalEntries(plan, entries) {
2155
2157
  };
2156
2158
  }
2157
2159
 
2160
+ /**
2161
+ * THE WAITING FAMILIES THE READING TURN WITHHELD, keyed by the question each one asks.
2162
+ *
2163
+ * A wider family waits in the plan until the reading turn asks for it, and the turn records each family it
2164
+ * leaves unasked as withheld-by-judgment, with its reason (withheld-families.mjs). The fold above lets a
2165
+ * question through when it matches a waiting family, because the turn's own ask of that question is what the
2166
+ * family waits for. That rule cannot tell the turn's ask from anyone else's, and the blind frame comparison's
2167
+ * re-sweep turns its requests into questions by code: its contains question on the mark is such a family, so
2168
+ * it asked families the turn had withheld. This map is what lets the re-sweep tell them apart.
2169
+ *
2170
+ * A family is in it only when all of these hold, because each one missing is a way a question could inherit
2171
+ * a judgment nobody made:
2172
+ * · it waits for the reading turn in this plan: a required row has no withholding to inherit;
2173
+ * · the turn's record names it, on its own axis, with a reason: a family nobody judged is still open;
2174
+ * · it is not released: a release replaces the withholding;
2175
+ * · no plan row that does not wait asks the same question: that row is the one that answers it, and the
2176
+ * fold's duplicate rule names it as the twin.
2177
+ * `withheld` is the record as readWithheldFamilies returns it, `{ [qid]: { axis, reason } }`, and `released`
2178
+ * the set releasedFamilyQids returns. The caller reads both, so this stays PURE.
2179
+ * @returns {Map<string, {qid: string, reason: string}>} question key → the withheld family and its reason
2180
+ */
2181
+ export function withheldFamilyTwins(plan, withheld = {}, released = new Set()) {
2182
+ const entries = Array.isArray(plan?.entries) ? plan.entries : [];
2183
+ const asked = new Set(entries.filter((e) => e && e.unsupported !== true && !awaitsReadingTurn(e.when))
2184
+ .map((e) => entryQuestionKey(e, plan)).filter(Boolean));
2185
+ const twins = new Map();
2186
+ for (const e of entries) {
2187
+ if (!e?.qid || e.unsupported === true || !awaitsReadingTurn(e.when) || released?.has?.(e.qid)) continue;
2188
+ const w = withheld?.[e.qid];
2189
+ const reason = String(w?.reason ?? "").trim();
2190
+ if (!reason || (w?.axis != null && String(w.axis) !== String(e.axis ?? ""))) continue;
2191
+ const key = entryQuestionKey(e, plan);
2192
+ if (key && !asked.has(key) && !twins.has(key)) twins.set(key, { qid: e.qid, reason });
2193
+ }
2194
+ return twins;
2195
+ }
2196
+
2197
+ /**
2198
+ * WHAT A RE-SWEEP THAT ADDS NOTHING STILL OWES ITS AXIS: the axis's entries that have not landed. The executor reads
2199
+ * an empty question list as every entry on the axis, so a fold that added nothing used to repeat every landed
2200
+ * question and, with them, retry the ones still unanswered. These are that retry without the repeats: every entry
2201
+ * on the axis the provider can express (`unsupported` is never sent), a family waiting for the reading turn only once
2202
+ * the turn released it, less every entry the join of these bands counts as executed, a released family's block
2203
+ * included. So it sends a required search that did not land, and also what the old re-send retried among the
2204
+ * unlanded: an entry whose provider error spent its retry ladder (a real call, as before), a capability refusal not
2205
+ * yet accepted as a gap (refused again without a call), and a fringe entry whose guard the executor still holds
2206
+ * (no call). The dispatcher drops the capability gaps the run has already accepted. PURE.
2207
+ * @param {object} plan the frozen register plan
2208
+ * @param {string} axis the axis the re-sweep ran on
2209
+ * @param {object} bandsByAxis the run's register bands, as readRegisterBands returns them
2210
+ * @param {Set<string>} released the families the reading turn released (releasedFamilyQids)
2211
+ */
2212
+ export function pendingRequiredQids(plan, axis, bandsByAxis = {}, released = new Set()) {
2213
+ const landed = new Set(joinPlanToBands(plan, bandsByAxis, { released }).executed.map((x) => x.qid));
2214
+ return (Array.isArray(plan?.entries) ? plan.entries : [])
2215
+ .filter((e) => e?.qid && e.axis === axis && e.unsupported !== true && (!awaitsReadingTurn(e.when) || released?.has?.(e.qid))
2216
+ && !landed.has(e.qid))
2217
+ .map((e) => e.qid);
2218
+ }
2219
+
2220
+ /**
2221
+ * Refuse the minted entries that repeat a withheld family exactly, before they reach the fold. "Exactly" is
2222
+ * the fold's own question identity (entryQuestionKey: offices resolved, classes, goods, owner, term form), so
2223
+ * the same term asked in other classes, other offices or narrowed by goods is a different question and is
2224
+ * kept. Each refusal is `withheld-by-judgment`, its twin is the family, and it carries the reason on the
2225
+ * family's record, so the closure rule (close-verify.mjs, withheldAs) settles the slice by that judgment:
2226
+ * the slice is never frozen into the plan and never runs, and the family stays waiting, so the reading turn
2227
+ * can still release it.
2228
+ *
2229
+ * NOT A SCREEN IN THE FOLD ITSELF, deliberately. The fold is the funnel for the reading turn's own proposals
2230
+ * as well, and the turn's ask of a family it once withheld is that turn reconsidering, which the waiting rule
2231
+ * exists to let through. Only the re-sweep, which asks on nobody's judgment, calls this.
2232
+ *
2233
+ * Every other entry is KEPT for the fold, which screens it as before. An entry whose qid the plan already
2234
+ * holds is kept too: the fold reads it as a re-proposal of a row that is already there. PURE.
2235
+ * @returns {{ kept: object[], refused: {qid: string, term: string, kind: "withheld-by-judgment", twin: string, reason: string}[] }}
2236
+ */
2237
+ export function refuseWithheldFamilies(entries, twins, plan) {
2238
+ const inPlan = new Set((plan?.entries ?? []).map((e) => e?.qid));
2239
+ const kept = [], refused = [];
2240
+ for (const e of Array.isArray(entries) ? entries : []) {
2241
+ const key = e?.qid && !inPlan.has(e.qid) ? entryQuestionKey(e, plan) : null;
2242
+ const twin = key ? twins?.get?.(key) : undefined;
2243
+ if (!twin) { kept.push(e); continue; }
2244
+ refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "withheld-by-judgment", twin: twin.qid, reason: twin.reason });
2245
+ }
2246
+ return { kept, refused };
2247
+ }
2248
+
2158
2249
  /**
2159
2250
  * The store decision (pure — the attach step does only IO around this): given the slug store's
2160
2251
  * latest plan (or null) and a fresh compile, decide reuse / extend / mint.
@@ -2319,6 +2410,110 @@ export function parseRegisterPlan(raw) {
2319
2410
  return { ...p };
2320
2411
  }
2321
2412
 
2413
+ // ── WHAT THE RUN RETRIEVED UNDER EACH CROWD ──────────────────────────────────────────────────────────
2414
+ //
2415
+ // A crowd is a question that ran and counted more than it read. This records, from the run's own blocks and plan,
2416
+ // what was retrieved under it, and nothing more: whether the reading happened, and whether a crowd is set aside, are
2417
+ // the reading step's and the write-up's judgments, recorded in their own places. Retrieving a list is not reading it.
2418
+ //
2419
+ // A NARROWING ASKS THE SAME QUESTION WITH TIGHTER LIMITS: the same predicate and the same terms, classes and markets
2420
+ // within the parent's, and the parent's goods words or fewer of them (a parent with none takes the client's goods
2421
+ // words, as the plan holds them), with the same owner. A list counts when its block is `enumerated`: complete, with no
2422
+ // error. Only lists reached through such narrowings count, at any depth.
2423
+ // list-retrieved a wider question with at least one narrowed list retrieved in full (named in `lists`).
2424
+ // no-list a wider question with none: the reading step decides it, with its count on the record.
2425
+ // required-covered the identical mark's own question, with a retrieved list in every class it asks, in
2426
+ // every main market (the order's countries, else the customer's priority markets, else the
2427
+ // major markets), or worldwide where the run names none.
2428
+ // required-not-covered the identical mark's own question without that: the class and market cells no retrieved
2429
+ // list covers are named in `not_covered`.
2430
+ // A refused or failed question is not a crowd: the join keeps those as `outstanding` and `missing`. Each reader (the
2431
+ // receipt, the coverage form, the band shape) works this out from the blocks it reads itself. It changes no row's
2432
+ // state and no sentence. PURE.
2433
+ const WORLDWIDE = "worldwide";
2434
+ export function crowdNarrowing(plan, byQid, { mainMarkets = [] } = {}) {
2435
+ const entries = Array.isArray(plan?.entries) ? plan.entries : [];
2436
+ const identicalTerms = identicalMarkTerms(plan);
2437
+ const childrenOf = new Map();
2438
+ for (const e of entries) {
2439
+ const parent = typeof e?.narrows === "string" ? e.narrows.trim() : "";
2440
+ if (!parent || !e?.qid) continue;
2441
+ if (!childrenOf.has(parent)) childrenOf.set(parent, []);
2442
+ childrenOf.get(parent).push(e);
2443
+ }
2444
+ const strs = (v) => (Array.isArray(v) ? v.map((x) => String(x).trim()).filter(Boolean) : []);
2445
+ const fold = (s) => String(s ?? "").normalize("NFKC").trim().toLowerCase().replace(/\s+/g, " ");
2446
+ const marketsOf = (v) => [...new Set(strs(v).map((t) => territoryKey(t) || t.toUpperCase()))];
2447
+ const regionsOf = (e) => (strs(e?.regions).length ? marketsOf(e.regions) : marketsOf(plan?.regions));
2448
+ const classesOf = (e) => [...new Set(strs(e?.nice_classes))];
2449
+ const termsOf = (e) => [...new Set((Array.isArray(e?.terms) && e.terms.length ? e.terms : [e?.term]).map(fold).filter(Boolean))].sort();
2450
+ const goodsOf = (e) => [...new Set(goodsTermsList(e).map(fold).filter(Boolean))];
2451
+ // the client's goods words, as the plan's own goods questions carry them
2452
+ const clientGoods = new Set(entries.filter((e) => e?.provenance === "mark").flatMap(goodsOf));
2453
+ const within = (inner, outer) => !outer.length || (inner.length > 0 && inner.every((x) => outer.includes(x)));
2454
+ const sameTerms = (a, b) => a.length === b.length && a.every((t, i) => t === b[i]);
2455
+ const narrows = (parent, child) => {
2456
+ if (String(child.predicate ?? "") !== String(parent.predicate ?? "") || !sameTerms(termsOf(child), termsOf(parent))) return false;
2457
+ if (!within(classesOf(child), classesOf(parent)) || !within(regionsOf(child), regionsOf(parent))) return false;
2458
+ if (String(parent.owner ?? "").trim() !== String(child.owner ?? "").trim()) return false;
2459
+ const pg = goodsOf(parent), cg = goodsOf(child);
2460
+ if (pg.length) return cg.length > 0 && cg.every((g) => pg.includes(g));
2461
+ return !cg.length || !clientGoods.size || cg.every((g) => clientGoods.has(g));
2462
+ };
2463
+ const stateOf = (qid) => { const b = byQid.get(qid); return b && b.error !== true ? String(b.state ?? "").toLowerCase() : null; };
2464
+ const retrievedUnder = (e, seen) => {
2465
+ const out = [];
2466
+ for (const c of childrenOf.get(e.qid) ?? []) {
2467
+ if (seen.has(c.qid) || !narrows(e, c)) continue;
2468
+ seen.add(c.qid);
2469
+ if (stateOf(c.qid) === "enumerated") out.push(c);
2470
+ out.push(...retrievedUnder(c, seen));
2471
+ }
2472
+ return out;
2473
+ };
2474
+ const main = marketsOf(mainMarkets);
2475
+ const rows = [];
2476
+ for (const e of entries) {
2477
+ if (!e?.qid || e.expected_kind === "count" || stateOf(e.qid) !== "incomplete") continue;
2478
+ const lists = retrievedUnder(e, new Set([e.qid]));
2479
+ const total = byQid.get(e.qid)?.total_hits;
2480
+ const row = { qid: e.qid, axis: e.axis ?? null, ...(Number.isFinite(Number(total)) && total != null ? { total_hits: Number(total) } : {}) };
2481
+ const named = lists.length ? { lists: lists.map((c) => c.qid).slice(0, 12) } : {};
2482
+ if (!asksIdenticalMark(e, identicalTerms)) {
2483
+ rows.push({ ...row, narrowing: lists.length ? "list-retrieved" : "no-list", ...named });
2484
+ continue;
2485
+ }
2486
+ // THE REQUIRED READ, CELL BY CELL: every class the question asks, in every main market inside its scope, or the
2487
+ // whole scope where the run names no main market. A worldwide scope is one cell per class, and only a worldwide
2488
+ // list covers it: an empty market list is never read as "every market covered".
2489
+ const scope = regionsOf(e);
2490
+ const inScope = main.length ? (scope.length ? main.filter((m) => scope.includes(m)) : main) : [];
2491
+ const cellMarkets = inScope.length ? inScope : (scope.length ? scope : [WORLDWIDE]);
2492
+ const cellClasses = classesOf(e).length ? classesOf(e) : ["*"];
2493
+ const covers = (c, m) => lists.some((d) => {
2494
+ const dc = classesOf(d), dr = regionsOf(d);
2495
+ const classOk = c === "*" ? !dc.length : (!dc.length || dc.includes(c));
2496
+ const marketOk = m === WORLDWIDE ? !dr.length : (!dr.length || dr.includes(m));
2497
+ return classOk && marketOk;
2498
+ });
2499
+ const notCovered = [];
2500
+ for (const c of cellClasses) for (const m of cellMarkets) if (!covers(c, m)) notCovered.push(c === "*" ? m : `${c}/${m}`);
2501
+ rows.push(notCovered.length
2502
+ ? { ...row, narrowing: "required-not-covered", ...named, not_covered: notCovered.slice(0, 40) }
2503
+ : { ...row, narrowing: "required-covered", ...named });
2504
+ }
2505
+ const count = (k) => rows.filter((r) => r.narrowing === k).length;
2506
+ return { list_retrieved: count("list-retrieved"), no_list: count("no-list"), required_covered: count("required-covered"),
2507
+ required_not_covered: count("required-not-covered"), rows };
2508
+ }
2509
+
2510
+ /** Every band block with a qid, by qid, across the axes: the blocks a crowd's ending is read from. PURE. */
2511
+ export function blocksByQidOf(bandBlocksByAxis) {
2512
+ const byQid = new Map();
2513
+ for (const arr of Object.values(bandBlocksByAxis ?? {})) for (const b of arr ?? []) if (b && typeof b.qid === "string" && b.qid) byQid.set(b.qid, b);
2514
+ return byQid;
2515
+ }
2516
+
2322
2517
  // ── fan-in: identity join plan ⇄ band blocks ────────────────────────────────────────────────────
2323
2518
 
2324
2519
  /**
@@ -2334,7 +2529,7 @@ export function parseRegisterPlan(raw) {
2334
2529
  * An entry whose `when` guard resolves FALSE (parent crowd/absent) is `skipped`, never `missing`.
2335
2530
  * Blocks with no qid join nothing (legacy bands) and surface as unplanned only when they carry one.
2336
2531
  */
2337
- export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() } = {}) {
2532
+ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set(), outstanding = new Set(), mainMarkets = [] } = {}) {
2338
2533
  const blocks = [];
2339
2534
  for (const [axis, arr] of Object.entries(bandBlocksByAxis ?? {})) {
2340
2535
  for (const b of arr ?? []) if (b && typeof b === "object") blocks.push({ ...b, _axis: axis });
@@ -2343,6 +2538,11 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
2343
2538
  for (const b of blocks) if (typeof b.qid === "string" && b.qid) byQid.set(b.qid, b);
2344
2539
 
2345
2540
  const executed = [], missing = [], skipped = [], deferred = [], awaiting = [], asked = [];
2541
+ // A FOLLOW-UP THE RUN'S SAFETY NET DID NOT SEND (providers/_shared/follow-up-searches.mjs) that holds no answer is a
2542
+ // question not asked, never one that went missing: missing sends a question back to be run, and the net would hold
2543
+ // it again. Only a follow-up can be refused; the plan's own questions never are.
2544
+ const notAsked = [];
2545
+ const refusedFollowUp = (e) => outstanding.has(e?.qid) && e?.origin === "supplemental";
2346
2546
  // A WAITING FAMILY THE READING TURN ASKED IS NOT WAITING. Its ask arrives as an ordinary entry — a
2347
2547
  // supplemental with the same question — and the family's own row stays guarded forever, so without this
2348
2548
  // link every family asked read as never asked. Matched on the question (entryQuestionKey), against
@@ -2352,6 +2552,12 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
2352
2552
  if (e?.unsupported === true || awaitsReadingTurn(e?.when)) continue;
2353
2553
  const k = entryQuestionKey(e, plan);
2354
2554
  if (k && !askedBy.has(k)) askedBy.set(k, e.qid);
2555
+ // A LIST ASKS EACH OF ITS SPELLINGS, with the list's own scope: a family asked inside a batch that went to the
2556
+ // register as one list is asked by that list.
2557
+ if (Array.isArray(e?.terms) && e.terms.length > 1) for (const t of e.terms) {
2558
+ const kt = entryQuestionKey({ ...e, terms: undefined, term: t }, plan);
2559
+ if (kt && !askedBy.has(kt)) askedBy.set(kt, e.qid);
2560
+ }
2355
2561
  }
2356
2562
  for (const e of plan.entries) {
2357
2563
  if (e.when) {
@@ -2391,7 +2597,7 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
2391
2597
  }
2392
2598
  }
2393
2599
  const b = byQid.get(e.qid);
2394
- if (!b) { missing.push(e.qid); continue; }
2600
+ if (!b) { if (refusedFollowUp(e)) { notAsked.push({ qid: e.qid, axis: e.axis }); continue; } missing.push(e.qid); continue; }
2395
2601
  // A DETERMINISTIC CAPABILITY GAP (providers/_shared/execute-plan.mjs stamps `deferred:true` next to
2396
2602
  // `error:true`): the active provider genuinely cannot express this slice — a predicate it lacks, an
2397
2603
  // office outside its vocabulary, a term its query language cannot state. Retrying is pointless: the
@@ -2405,7 +2611,7 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
2405
2611
  // incomplete shape — the dictated slice never really ran. It joins as MISSING (→ the warm
2406
2612
  // followup re-invokes the executor; exhaustion is an honest StageFailure), never as an
2407
2613
  // "executed" crowd — a transient must not ship indistinguishable from a sanctioned descriptor.
2408
- if (b.error === true) { missing.push(e.qid); continue; }
2614
+ if (b.error === true) { if (refusedFollowUp(e)) { notAsked.push({ qid: e.qid, axis: e.axis }); continue; } missing.push(e.qid); continue; }
2409
2615
  // — WHAT THE SLICE RETURNED RIDES WITH THE FACT THAT IT RAN.
2410
2616
  //
2411
2617
  // "executed" answers "did the query run". It has never answered "did anything come back", and the
@@ -2447,7 +2653,10 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
2447
2653
  const planQids = new Set(plan.entries.map((e) => e.qid));
2448
2654
  const unplanned = blocks.filter((b) => typeof b.qid === "string" && b.qid && !planQids.has(b.qid))
2449
2655
  .map((b) => ({ qid: b.qid, query: String(b.query ?? "").slice(0, 80) }));
2450
- return { executed, missing, skipped, deferred, awaiting, asked, unplanned };
2656
+ // what was retrieved under each crowd, worked out from the same blocks (crowdNarrowing, above): on the receipt only when there is a crowd
2657
+ const crowds = crowdNarrowing(plan, byQid, { mainMarkets });
2658
+ return { executed, missing, skipped, deferred, awaiting, asked, unplanned, ...(notAsked.length ? { outstanding: notAsked } : {}),
2659
+ ...(crowds.rows.length ? { crowd_narrowing: crowds } : {}) };
2451
2660
  }
2452
2661
 
2453
2662
  // ──: the slice the provider ACCEPTED and then hard-errored at RUN time ──────────────────────────
@@ -2650,6 +2859,7 @@ export function deriveCoverageSkeleton(plan, join) {
2650
2859
  const skippedQids = new Set(join.skipped.map((x) => x.qid));
2651
2860
  const deferredQids = new Set((join.deferred ?? []).map((x) => x.qid));
2652
2861
  const awaitingQids = new Set((join.awaiting ?? []).map((x) => x.qid));
2862
+ const outstandingQids = new Set((join.outstanding ?? []).map((x) => x.qid));
2653
2863
  const askedQids = new Set((join.asked ?? []).map((x) => x.qid));
2654
2864
  // ── A COUNT THE PLAN ALSO READ IN FULL IS NOT A CROWD ──────────────────────────────────────────
2655
2865
  // A count-only descriptor (the saturation probe's shape) sizes a question and reads none of its
@@ -2674,7 +2884,7 @@ export function deriveCoverageSkeleton(plan, join) {
2674
2884
  && readInFull.get(question(e)) === totalByQid.get(e.qid);
2675
2885
  const axes = new Map();
2676
2886
  for (const e of plan.entries) {
2677
- if (!axes.has(e.axis)) axes.set(e.axis, { axis: e.axis, entries: 0, executed: 0, crowds: 0, missing: [], skipped: 0, deferred: [], awaiting: 0, asked: 0, readElsewhere: 0 });
2887
+ if (!axes.has(e.axis)) axes.set(e.axis, { axis: e.axis, entries: 0, executed: 0, crowds: 0, missing: [], skipped: 0, deferred: [], awaiting: 0, asked: 0, readElsewhere: 0, outstanding: 0 });
2678
2888
  const a = axes.get(e.axis);
2679
2889
  a.entries++;
2680
2890
  // COUNTED BEFORE `skipped`, and separately from it. With the families waiting on every matter this is the ordinary state of
@@ -2686,6 +2896,8 @@ export function deriveCoverageSkeleton(plan, join) {
2686
2896
  // where it ran; counting the family as executed too would count one question twice.
2687
2897
  if (askedQids.has(e.qid)) { a.asked++; continue; }
2688
2898
  if (skippedQids.has(e.qid)) { a.skipped++; continue; }
2899
+ // a follow-up the run's safety net did not send: not asked, so neither run nor missing
2900
+ if (outstandingQids.has(e.qid)) { a.outstanding++; continue; }
2689
2901
  if (missing.has(e.qid)) { a.missing.push(e.qid); continue; }
2690
2902
  // a capability gap is NOT executed — it is a disclosed hole in the axis
2691
2903
  if (deferredQids.has(e.qid)) { a.deferred.push(e.qid); continue; }
@@ -2713,9 +2925,12 @@ export function deriveCoverageSkeleton(plan, join) {
2713
2925
  : (a.deferred.length ? "deferred"
2714
2926
  : (a.crowds ? "incomplete"
2715
2927
  : (a.executed === 0 && a.skipped > 0 ? "skipped"
2716
- : (a.executed === 0 && a.awaiting > 0 ? "awaiting-judgment" : "executed")))),
2928
+ : (a.executed === 0 && a.awaiting > 0 ? "awaiting-judgment"
2929
+ // nothing ran but follow-ups the safety net did not send: no answer to rest a clean on, as when they joined missing
2930
+ : (a.executed === 0 && a.outstanding > 0 ? "unexecuted" : "executed"))))),
2717
2931
  entries: a.entries, executed: a.executed, crowds: a.crowds, skipped: a.skipped, missing: a.missing,
2718
2932
  ...(a.awaiting ? { awaiting: a.awaiting } : {}),
2933
+ ...(a.outstanding ? { outstanding: a.outstanding } : {}),
2719
2934
  ...(a.asked ? { asked: a.asked } : {}),
2720
2935
  ...(a.readElsewhere ? { read_elsewhere: a.readElsewhere } : {}),
2721
2936
  ...(a.deferred.length ? { deferred: a.deferred } : {}),