clearotron 0.4.0 → 0.4.1-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.
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/bin/README.md +1 -1
- package/bin/clearotron.mjs +2 -0
- package/bin/mcp.mjs +80 -0
- package/bin/start.mjs +14 -1
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/docs/SECURITY-OWASP.md +21 -13
- package/docs/SECURITY.md +20 -14
- package/docs/architecture/04-configuration-reference.md +3 -3
- package/docs/architecture/05-config-governance.md +1 -1
- package/docs/architecture/06-operations-runbook.md +5 -4
- package/docs/architecture/08-development-guide.md +3 -1
- package/docs/architecture/09-security-and-data.md +16 -11
- package/driver/CHANGELOG.md +29 -0
- package/driver/binding-layers.mjs +28 -6
- package/driver/citation-census.json +3 -3
- package/driver/common-law-coverage-status.mjs +52 -0
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/compare.mjs +1 -1
- package/driver/connotation-search.mjs +5 -5
- package/driver/contract-audit.mjs +6 -6
- package/driver/contract-dictation-registry.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +18 -17
- package/driver/contract-vocabulary.mjs +145 -132
- package/driver/declination-call.mjs +5 -1
- package/driver/degraded-parts.mjs +10 -3
- package/driver/dev-portal.mjs +2 -1
- package/driver/dispatch-record.mjs +19 -0
- package/driver/doubt-closure-tool.mjs +2 -2
- package/driver/driver.config.mjs +39 -6
- package/driver/engagement-receipt.mjs +1 -1
- package/driver/engine/CONTRACT.md +41 -1
- package/driver/engine/anthropic-agent.mjs +56 -4
- package/driver/engine/mcp/codex-config.mjs +8 -3
- package/driver/engine/mcp/coverage-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +32 -5
- package/driver/engine/mcp/stdio-server.mjs +45 -7
- package/driver/engine/mcp/supplemental.mjs +26 -1
- package/driver/engine/openai-agent.mjs +113 -20
- package/driver/engine/session-record.mjs +223 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/findings-model.mjs +4 -4
- package/driver/flag-snapshot.mjs +2 -1
- package/driver/form-neighbourhood.mjs +7 -7
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/framework.mjs +22 -0
- package/driver/gateway.mjs +96 -14
- package/driver/jx.mjs +1 -1
- package/driver/knockout-review-record.mjs +55 -1
- package/driver/named-band.mjs +4 -0
- package/driver/owner-screen.mjs +7 -4
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +13 -2
- package/driver/pipeline-knockout.mjs +15 -13
- package/driver/pipeline.mjs +132 -48
- package/driver/portal-families.mjs +3 -3
- package/driver/portal-service.mjs +6 -5
- package/driver/predelivery-lint.mjs +19 -19
- package/driver/products.mjs +1 -1
- package/driver/profiles.mjs +4 -3
- package/driver/publish/index.mjs +1 -1
- package/driver/publish/knockout.mjs +5 -4
- package/driver/publish/office-record-links.mjs +17 -2
- package/driver/publish/publish-inputs.mjs +3 -3
- package/driver/publish/render-knockout.mjs +17 -5
- package/driver/publish/render.mjs +4 -2
- package/driver/publish/xlsx.mjs +1 -1
- package/driver/record-fold.mjs +177 -0
- package/driver/register-count.mjs +20 -3
- package/driver/register-coverage.mjs +1 -1
- package/driver/register-plan.mjs +83 -18
- package/driver/register-records.mjs +7 -0
- package/driver/registry-fidelity.mjs +2 -2
- package/driver/repairs.mjs +1 -1
- package/driver/report-card-record.mjs +4 -4
- package/driver/result-noun-fields.mjs +3 -3
- package/driver/scope-facts.mjs +11 -3
- package/driver/scope-rules.mjs +2 -2
- package/driver/search-policy.mjs +20 -1
- package/driver/skills/clearance-common-law/SKILL.md +22 -22
- package/driver/skills/clearance-register/digest.md +3 -3
- package/driver/skills/clearance-register/register-recipes.md +11 -11
- package/driver/skills/clearance-register/status-rules.md +1 -1
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
- package/driver/skills/clearance-search/phase2-execution.md +6 -5
- package/driver/skills/clearance-search/synthesis-rules.md +4 -4
- package/driver/skills/clearance-search/worked-examples.md +39 -39
- package/driver/skills/clearance-variants/SKILL.md +16 -16
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/stage-context.mjs +36 -1
- package/driver/stages-knockout.mjs +6 -5
- package/driver/stages.mjs +30 -30
- package/driver/suite-census.json +259 -55
- package/driver/synthesis-record.mjs +5 -0
- package/driver/unit-inventory.mjs +17 -28
- package/driver/verify.mjs +7 -7
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/CONNECT.md +9 -3
- package/mcp-server/demo-mode.mjs +10 -0
- package/mcp-server/lib/evidence.mjs +8 -8
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/runs.mjs +7 -1
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/package.json +2 -2
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/server.mjs +16 -2
- package/package.json +4 -3
- package/portal-ui/package.json +3 -3
- package/providers/_shared/count.mjs +9 -2
- package/providers/_shared/enumerate.mjs +125 -41
- package/providers/_shared/execute-plan.mjs +15 -4
- package/providers/corsearch/src/capabilities.js +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +2 -2
- package/providers/signa/src/capabilities.js +32 -2
- package/providers/signa/src/core.js +262 -36
- package/scripts/band-shape-probe.mjs +2 -2
- package/scripts/deprecate-below.mjs +28 -4
- package/scripts/e2e-first-time.mjs +17 -2
- package/scripts/env-audit.mjs +3 -2
- package/scripts/freeze-example-run.mjs +10 -10
- package/scripts/mcp-registry-listing.mjs +199 -0
- package/scripts/mint-plugin-folder.mjs +65 -0
- package/scripts/release-version.mjs +44 -0
- package/scripts/score.mjs +1 -1
- package/shared/register-floor.mjs +19 -0
- package/shared/scope.mjs +18 -2
- package/shared/trigger-lane.mjs +1 -1
- package/skills/clearotron-ops/SKILL.md +81 -24
|
@@ -15,7 +15,7 @@ import { riskTier, TONE_TIER, regenIndex, regenSurfaces, auditRouteFor, markRepo
|
|
|
15
15
|
import { runKnockoutLint, deliveryFlagLines } from '../predelivery-lint.mjs';
|
|
16
16
|
import { note } from '../log.mjs';
|
|
17
17
|
import { addSheet } from './xlsx.mjs';
|
|
18
|
-
import { COUNT_PREDICATES, COUNT_BASIS, countsForMark, countLine, countedMarks, variantFormsLine, disclosedFloor, moreThan } from '../register-count.mjs';
|
|
18
|
+
import { COUNT_PREDICATES, COUNT_BASIS, countsForMark, countLine, countedMarks, variantFormsLine, disclosedFloor, moreThan, countNote } from '../register-count.mjs';
|
|
19
19
|
import { RECORD_BASIS, recordsForMark, recordsLine, listedMarks, normalizeRegisterRecordLinks } from '../register-records.mjs';
|
|
20
20
|
import { reportIdentityFor, productCoverageNote, kebab } from '../search-policy.mjs';
|
|
21
21
|
import { batchMarkName } from '../mark-name.mjs';
|
|
@@ -235,10 +235,11 @@ export async function buildKnockoutWorkbook(findings, receipts, outPath, registe
|
|
|
235
235
|
row['Close variation forms'] = variantFormsLine(e) ?? 'not recorded — this run predates the close-variation column';
|
|
236
236
|
row['Register'] = registerCounts.providerLabel ?? registerCounts.provider ?? '';
|
|
237
237
|
row['Basis'] = COUNT_BASIS;
|
|
238
|
-
// The
|
|
238
|
+
// The reader's line for anything missing. The provider's own reason, or an exception's message, stays
|
|
239
|
+
// in the counts record where it was written (register-count.mjs, countNote).
|
|
239
240
|
row['Notes'] = COUNT_PREDICATES
|
|
240
241
|
.filter((p) => !Number.isFinite(e?.counts?.[p.key]?.total) && disclosedFloor(e?.counts?.[p.key]) === null)
|
|
241
|
-
.map((p) => `${p.label}: ${e?.counts?.[p.key]
|
|
242
|
+
.map((p) => `${p.label}: ${countNote(e?.counts?.[p.key]) ?? 'no count recorded'}`)
|
|
242
243
|
.join(' · ');
|
|
243
244
|
return row;
|
|
244
245
|
});
|
|
@@ -369,7 +370,7 @@ export function composeKnockoutEmail({ findings, framework, overall, reports = [
|
|
|
369
370
|
// says how to read the list instead: a "full report" link on a batch could only be one name standing
|
|
370
371
|
// for all of them. A single-mark run keeps its head link exactly as before — same bytes, same place.
|
|
371
372
|
const only = n === 1 ? urlByMark.get((findings.marks ?? [])[0]?.name) ?? null : null;
|
|
372
|
-
const head = `<p style="${FONT}">Knockout trademark review${job?.ref ? ` — ${esc(job.ref)}` : ''}: <b>${n} mark${n === 1 ? '' : 's'}</b>,
|
|
373
|
+
const head = `<p style="${FONT}">Knockout trademark review${job?.ref ? ` — ${esc(job.ref)}` : ''}: <b>${n} mark${n === 1 ? '' : 's'}</b>, ${n === 1 ? 'rating' : 'highest rating'} <b style="color:${t.txt}">${esc(overall ?? '')}</b>.`
|
|
373
374
|
+ (only ? ` <a href="${esc(only)}">Open the full report</a>${auditUrl ? ` · <a href="${esc(auditUrl)}">audit workbook</a>` : ''}.` : '')
|
|
374
375
|
+ (!only && n > 1 ? ` One report per name below${auditUrl ? `, and one <a href="${esc(auditUrl)}">audit workbook</a> for the batch` : ''}.` : '') + `</p>`;
|
|
375
376
|
// — THE FIRST NON-BLANK, never element 0. `?? fallback` catches null and undefined
|
|
@@ -122,10 +122,25 @@ export function officeRecordLink(rec, uri = "") {
|
|
|
122
122
|
const page = OFFICE_RECORD_PAGES[office];
|
|
123
123
|
const own = designation ? clean(rec.irNumber) : clean(rec.applicationNumber) || clean(rec.registrationNumber);
|
|
124
124
|
const unlinked = (reason) => ({ office, label: labelOf(office, own), href: null, reason });
|
|
125
|
+
const a = page?.address ? (designation ? madridMonitor([rec.irNumber]) : page.address(rec)) : null;
|
|
126
|
+
if (a) return { office, label: labelOf(office, a.number), href: a.href, reason: null };
|
|
127
|
+
// THE REGISTER'S OWN LINK, ONLY WHERE THE TABLE GIVES NONE. Some registers hand over the office's page
|
|
128
|
+
// for a record (`officeUrl`). The table above stays first: each of its addresses was checked against a
|
|
129
|
+
// real record, and on the records measured the register's link pointed at the same office's site in a
|
|
130
|
+
// different form, or at the designated office's page where the table opens the international
|
|
131
|
+
// registration. So the register's link fills only a gap — an office with no address here, or a number
|
|
132
|
+
// the address cannot take — and never replaces an address the table builds. Only an https address, and
|
|
133
|
+
// only beside a number the reader can be shown.
|
|
134
|
+
const given = registerGivenUrl(rec.officeUrl);
|
|
135
|
+
if (given && own) return { office, label: labelOf(office, own), href: given, reason: null };
|
|
125
136
|
if (!page) return unlinked("unknown-office");
|
|
126
137
|
if (!page.address) return unlinked("no-page");
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
return unlinked("unaddressable");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function registerGivenUrl(v) {
|
|
142
|
+
if (typeof v !== "string") return null;
|
|
143
|
+
try { return new URL(v).protocol === "https:" ? v : null; } catch { return null; }
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
/**
|
|
@@ -121,8 +121,8 @@ export const NOT_READ_BY_NAME = {
|
|
|
121
121
|
// absent one is the caller's error and already throws. Declared so the source scan can tell "argument"
|
|
122
122
|
// from "undeclared store" instead of demanding a gating for something that has none.
|
|
123
123
|
export const CALLER_SUPPLIED = {
|
|
124
|
-
"report.md": "the reportMd ARGUMENT — read unconditionally
|
|
125
|
-
"audit.md": "the auditMd ARGUMENT — existence-gated
|
|
124
|
+
"report.md": "the reportMd ARGUMENT — read unconditionally by publishReport() in index.mjs; an unreadable one throws, which is correct (there is no report to publish)",
|
|
125
|
+
"audit.md": "the auditMd ARGUMENT — existence-gated in publishReport() in index.mjs; a run with no audit markdown legitimately publishes without a workbook",
|
|
126
126
|
};
|
|
127
127
|
|
|
128
128
|
/**
|
|
@@ -133,7 +133,7 @@ export const CALLER_SUPPLIED = {
|
|
|
133
133
|
* publish/index.mjs's source — reading a 1472-line sibling on every import to answer a question that
|
|
134
134
|
* only changes when someone edits the file is the wrong place for it; the dead-key/undeclared-store
|
|
135
135
|
* half runs in publish-input-coverage.test.mjs against the real source, on the
|
|
136
|
-
* dependency-repair.test.mjs
|
|
136
|
+
* dependency-repair.test.mjs precedent (its 15a and 15b tests).
|
|
137
137
|
*
|
|
138
138
|
* `tables` is injectable so the gate can be exercised against a partition nobody shipped, which is
|
|
139
139
|
* otherwise unreachable from a test.
|
|
@@ -51,7 +51,7 @@ import { SUMMARY_BLOCK_LINE, parseSummaryBlocks } from '../../shared/summary-blo
|
|
|
51
51
|
const STRIP = Object.freeze([
|
|
52
52
|
['summary', 'Summary'], ['findings', 'Findings'], ['filings', 'Also considered'], ['next', 'Next steps'],
|
|
53
53
|
]);
|
|
54
|
-
import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine, disclosedFloor, moreThan } from '../register-count.mjs';
|
|
54
|
+
import { COUNT_BASIS, COUNT_PREDICATES, countsForMark, countLine, variantFormsLine, disclosedFloor, moreThan, countNote } from '../register-count.mjs';
|
|
55
55
|
import { RECORD_BASIS, recordsForMark, recordsLine } from '../register-records.mjs';
|
|
56
56
|
import { officeLinkSentences } from './office-record-links.mjs';
|
|
57
57
|
import { knockoutFindingViews, splitKnockoutNotes } from '../findings-model.mjs';
|
|
@@ -66,6 +66,7 @@ import { makeClassifyStatus, isAllClass } from '../../providers/_shared/screen.m
|
|
|
66
66
|
import { saysSomethingNew } from '../../shared/says-something-new.mjs';
|
|
67
67
|
import { isNextStepHeading } from '../knockout-next-step.mjs';
|
|
68
68
|
import OFFERED from '../../shared/offered-territories.json' with { type: 'json' }; // the order form's own territory names
|
|
69
|
+
import { normalizeTerritory } from '../../providers/_shared/territory-codes.mjs'; // a territory as written → its code
|
|
69
70
|
|
|
70
71
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
71
72
|
|
|
@@ -492,6 +493,17 @@ const orderedTerritoryName = (code) => {
|
|
|
492
493
|
return ORDER_FORM_NAMES.get(c === 'EM' ? 'EU' : c) ?? '';
|
|
493
494
|
};
|
|
494
495
|
|
|
496
|
+
/**
|
|
497
|
+
* A territory as the order wrote it, as a code. An order carries a code ("EU", "UK") or a country's name
|
|
498
|
+
* ("Norway"), and both name lookups on this page take codes: a name reached them unconverted, came back
|
|
499
|
+
* empty and left the page without a trace (a delivered knockout, 2026-10-08, named two of the four
|
|
500
|
+
* territories it searched). The shared normaliser turns a name into its code; UK is the order form's GB.
|
|
501
|
+
*/
|
|
502
|
+
const orderCode = (t) => {
|
|
503
|
+
const c = normalizeTerritory(t) || String(t ?? '').trim().toUpperCase();
|
|
504
|
+
return c === 'UK' ? 'GB' : c;
|
|
505
|
+
};
|
|
506
|
+
|
|
495
507
|
/** "a, b and c" — the reader's list, not a join on commas. */
|
|
496
508
|
function listWords(items) {
|
|
497
509
|
const xs = (items ?? []).filter(Boolean);
|
|
@@ -561,7 +573,7 @@ function countsSection(marks, registerCounts, positions = '') {
|
|
|
561
573
|
// archived run re-rendering through today's renderer has no close-variation cell because the
|
|
562
574
|
// column did not exist when it was counted, and "no count recorded" would read as a defect in a
|
|
563
575
|
// report that has none. The hover says which.
|
|
564
|
-
const why = c
|
|
576
|
+
const why = countNote(c) ?? (c
|
|
565
577
|
? 'no count recorded'
|
|
566
578
|
: 'not counted on this run — it predates this column, and an archived report re-renders as what it was');
|
|
567
579
|
if (Number.isFinite(c?.total)) return `<td class="num">${esc(String(c.total))}</td>`;
|
|
@@ -1252,7 +1264,7 @@ function ownerCheckFor(ownerChecks, recordId) {
|
|
|
1252
1264
|
function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext = '', searched = '', registerCounts = null) {
|
|
1253
1265
|
const goods = String(scope?.goods ?? '').trim();
|
|
1254
1266
|
const classes = (Array.isArray(scope?.classes) ? scope.classes : []).filter((c) => c || c === 0);
|
|
1255
|
-
const jx = (Array.isArray(scope?.jurisdictions) ? scope.jurisdictions : []).map((t) => territoryName(t)).filter(Boolean);
|
|
1267
|
+
const jx = (Array.isArray(scope?.jurisdictions) ? scope.jurisdictions : []).map((t) => territoryName(orderCode(t))).filter(Boolean);
|
|
1256
1268
|
const asked = goods;
|
|
1257
1269
|
const classLine = classes.length ? classes.join(', ') : '';
|
|
1258
1270
|
const where = jx.length ? listWords(jx) : '';
|
|
@@ -1279,9 +1291,9 @@ function aboutRequestBlock(scope, requestNotes, depthNote = '', productContext =
|
|
|
1279
1291
|
// a territory nothing was counted in.
|
|
1280
1292
|
const on = provider ? `, on ${provider}` : '';
|
|
1281
1293
|
const sc = registerCounts?.scope ?? {};
|
|
1282
|
-
const deferred = new Set((sc.deferredJurisdictions ?? []).map(
|
|
1294
|
+
const deferred = new Set((sc.deferredJurisdictions ?? []).map(orderCode));
|
|
1283
1295
|
const ordered = [...new Set((sc.jurisdictions ?? [])
|
|
1284
|
-
.filter((c) => !deferred.has(
|
|
1296
|
+
.filter((c) => !deferred.has(orderCode(c))).map((c) => orderedTerritoryName(orderCode(c))))];
|
|
1285
1297
|
const namesTheOrder = sc.worldwide !== true && !(sc.unreachableOffices ?? []).length
|
|
1286
1298
|
&& ordered.length > 0 && ordered.every(Boolean);
|
|
1287
1299
|
const counted = namesTheOrder ? `${listWords(ordered)}${on}.`
|
|
@@ -1705,9 +1705,11 @@ function fullDetail(f, card, recordsByUri = new Map()) {
|
|
|
1705
1705
|
// 404-card caveat (2026-07-22) — a cited record the V4-2 closure fetch DEFINITIVELY could not
|
|
1706
1706
|
// retrieve (joinEvidenceStatus stamped `_recordFetchFailure` from the persisted failure list). ONE
|
|
1707
1707
|
// deterministic code-owned line on the card, client render included: the reader must never take this
|
|
1708
|
-
// card's registry values as record-verified. Absent stamp (no join ran / archived runs) ⇒ nothing.
|
|
1708
|
+
// card's registry values as record-verified. Absent stamp (no join ran / archived runs) ⇒ nothing. The
|
|
1709
|
+
// failure's cause is the record's (the persisted failure list and the stamp keep it), never the card's:
|
|
1710
|
+
// it can be an exception's message.
|
|
1709
1711
|
const fetchFailLine = f._recordFetchFailure
|
|
1710
|
-
? `<li class="openitem"><b>Official register record could not be retrieved
|
|
1712
|
+
? `<li class="openitem"><b>Official register record could not be retrieved</b> — registry details in this card are unverified.</li>`
|
|
1711
1713
|
: '';
|
|
1712
1714
|
const regs = (f.owner?.registrations || []).map(r => {
|
|
1713
1715
|
const uri = String(r.uri || '').toLowerCase();
|
package/driver/publish/xlsx.mjs
CHANGED
|
@@ -574,7 +574,7 @@ function coverageJudgmentSummaryRows(cj) {
|
|
|
574
574
|
const rows = Array.isArray(cj?.rows) ? cj.rows : [];
|
|
575
575
|
if (!rows.length) return [];
|
|
576
576
|
return [{ Field: '', Value: '' }, { Field: 'Coverage judgment — slices considered', Value: '', _head: true },
|
|
577
|
-
...rows.map((r) => ({ Field: ` ${plainNote(r.area)}`, Value: plainNote(r.note) }))];
|
|
577
|
+
...rows.map((r) => ({ Field: ` ${plainNote(r.areaLabel || r.area)}`, Value: plainNote(r.note) }))];
|
|
578
578
|
}
|
|
579
579
|
function correctionsSummaryRows(c) {
|
|
580
580
|
const entries = Array.isArray(c?.entries) ? c.entries : [];
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
//
|
|
4
|
+
// THE FOLDED RECORD IS THE RECORD FROM THE DELIVERY SEAM ON (design, 2026-10-03).
|
|
5
|
+
//
|
|
6
|
+
// Before the overview and the cards are built, the driver folds a second filing of the same conflict into
|
|
7
|
+
// one finding and renumbers the rest. A synthesis save after that seam — the pre-delivery lint's narrative
|
|
8
|
+
// repair, the stale repair, the fix pass — wrote the model's own record back over the folded one, and
|
|
9
|
+
// nothing folded it again. Measured on a test run: the folded filing stood as its own finding again, every
|
|
10
|
+
// finding after it moved down one place, 19 cards were rebuilt for findings that had only moved, and the
|
|
11
|
+
// last finding shipped without the prose card it was owed, because no card had ever been built at its
|
|
12
|
+
// ordinal.
|
|
13
|
+
//
|
|
14
|
+
// So once the seam is passed, every accepted save folds again on acceptance, and findings.json — what the
|
|
15
|
+
// reviewer after the seam, the cards and the delivery read — is always the folded record. Two things stay
|
|
16
|
+
// in the model's numbering, because the model wrote them in it: its own record, which stays the base for
|
|
17
|
+
// its patches (the call records the save keeps), and whatever is named against that record — a flag's
|
|
18
|
+
// ordinals, the ordinals a save records it touched, the narrative's finding headings. So the record the
|
|
19
|
+
// save wrote is kept beside the folded file while a fold is in force, with the fold's map both ways, and a
|
|
20
|
+
// reader that joins something in the model's numbering reads that record (`modelRecordPath`).
|
|
21
|
+
//
|
|
22
|
+
// THE STATE, in _driver/fold.json:
|
|
23
|
+
// seamPassed — this pass has passed the seam. A pass opens with it cleared (`openPass`) and the seam's
|
|
24
|
+
// own fold sets it, so a save before the seam on a resumed pass writes the model's record,
|
|
25
|
+
// as every save before the seam always has.
|
|
26
|
+
// map — {modelToFolded, foldedToModel} for findings.json as it stands; null when the file is
|
|
27
|
+
// the record as the save wrote it.
|
|
28
|
+
// and _driver/findings-unfolded.json — that record, in the model's numbering, kept only while a fold is
|
|
29
|
+
// in force.
|
|
30
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, rmSync, mkdirSync } from "node:fs";
|
|
31
|
+
import { dirname } from "node:path";
|
|
32
|
+
import { driverDir } from "../shared/driver-dir.mjs";
|
|
33
|
+
import { consolidateFindings, remapActionOrdinals, parseFindingsJson } from "./findings-model.mjs";
|
|
34
|
+
import { parseFrameworkManifest } from "./framework.mjs";
|
|
35
|
+
|
|
36
|
+
export const FOLD_FILE = "fold.json";
|
|
37
|
+
export const UNFOLDED_FILE = "findings-unfolded.json";
|
|
38
|
+
|
|
39
|
+
const bothWays = (ordinalMap) => {
|
|
40
|
+
const modelToFolded = {}, foldedToModel = {};
|
|
41
|
+
for (const [m, f] of ordinalMap) { modelToFolded[m] = f; (foldedToModel[f] ??= []).push(m); }
|
|
42
|
+
return { modelToFolded, foldedToModel };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fold a findings document as the delivery seam does: a second filing of the same conflict folds into one
|
|
47
|
+
* finding, the findings renumber, and the actions follow the renumber. `map` is null when nothing folds,
|
|
48
|
+
* and the document is then returned as it came. PURE.
|
|
49
|
+
*/
|
|
50
|
+
export function foldRecord(doc, manifest = null) {
|
|
51
|
+
const list = Array.isArray(doc?.findings) ? doc.findings : [];
|
|
52
|
+
if (list.length < 2) return { doc, merges: [], map: null };
|
|
53
|
+
const { findings, merges, ordinalMap } = consolidateFindings(list, (doc.schema_version ?? 1) >= 4 ? manifest : null);
|
|
54
|
+
if (!merges.length) return { doc, merges: [], map: null };
|
|
55
|
+
const next = { ...doc, findings };
|
|
56
|
+
// spec 64 — a merged-away ordinal remaps to its kept finding's new ordinal
|
|
57
|
+
if (Array.isArray(doc.actions)) next.actions = remapActionOrdinals(doc.actions, ordinalMap);
|
|
58
|
+
return { doc: next, merges, map: bothWays(ordinalMap) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The fold state of a run, or null before any seam. Never throws. */
|
|
62
|
+
export function readFold(runDir) {
|
|
63
|
+
try { return JSON.parse(readFileSync(driverDir(String(runDir ?? ""), FOLD_FILE), "utf8")); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeFold(runDir, { seamPassed, map = null, merges = [], error = null }) {
|
|
67
|
+
const at = driverDir(String(runDir ?? ""), FOLD_FILE);
|
|
68
|
+
mkdirSync(dirname(at), { recursive: true });
|
|
69
|
+
const tmp = `${at}.${process.pid}.tmp`;
|
|
70
|
+
writeFileSync(tmp, JSON.stringify({
|
|
71
|
+
_provenance: "the fold of findings.json as it stands: whether this pass has passed the delivery seam, and the map from the model's ordinals to the folded ones and back",
|
|
72
|
+
at: new Date().toISOString(), seamPassed: Boolean(seamPassed), map,
|
|
73
|
+
// ordinals only: the names stay in the record itself
|
|
74
|
+
merges: (merges ?? []).map((m) => ({ kept: m.kept, dropped: m.dropped })),
|
|
75
|
+
...(error ? { error } : {}),
|
|
76
|
+
}, null, 2) + "\n");
|
|
77
|
+
renameSync(tmp, at);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeAtomic(file, text) {
|
|
81
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
82
|
+
writeFileSync(tmp, text);
|
|
83
|
+
renameSync(tmp, file);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fold findings.json in place and record the fold. The seam calls it with `seam: true`; every accepted save
|
|
88
|
+
* calls it after writing the record, and it folds only once this pass has passed the seam. Before the seam
|
|
89
|
+
* the save wrote the model's record as it always has, and the state then says the file is unfolded.
|
|
90
|
+
*
|
|
91
|
+
* Returns `{merges}`, empty when nothing folded, and `error` when a fold could not be made. Never throws: a
|
|
92
|
+
* record that cannot be folded stays as the save wrote it, which is what every run did before this.
|
|
93
|
+
*/
|
|
94
|
+
export function foldFindingsFile(runDir, findingsPath, manifest, { seam = false } = {}) {
|
|
95
|
+
const dir = String(runDir ?? "");
|
|
96
|
+
const state = readFold(dir);
|
|
97
|
+
const unfoldedAt = driverDir(dir, UNFOLDED_FILE);
|
|
98
|
+
let folded = null; // the fold, once findings.json holds it
|
|
99
|
+
try {
|
|
100
|
+
if (!seam && state?.seamPassed !== true) {
|
|
101
|
+
if (state?.map) { writeFold(dir, { seamPassed: false }); rmSync(unfoldedAt, { force: true }); }
|
|
102
|
+
return { merges: [] };
|
|
103
|
+
}
|
|
104
|
+
if (!existsSync(findingsPath)) { writeFold(dir, { seamPassed: true }); return { merges: [] }; }
|
|
105
|
+
const raw = readFileSync(findingsPath, "utf8");
|
|
106
|
+
const { doc, merges, map } = foldRecord(JSON.parse(raw), manifest);
|
|
107
|
+
if (!merges.length) {
|
|
108
|
+
// Nothing folds. On a resumed pass the file can be the earlier pass's fold with no save since: the
|
|
109
|
+
// record and the map it was folded by both stand.
|
|
110
|
+
const held = seam && state?.map && existsSync(unfoldedAt);
|
|
111
|
+
writeFold(dir, held ? { seamPassed: true, map: state.map, merges: state.merges } : { seamPassed: true });
|
|
112
|
+
if (!held) rmSync(unfoldedAt, { force: true });
|
|
113
|
+
return { merges: [] };
|
|
114
|
+
}
|
|
115
|
+
parseFindingsJson(JSON.stringify(doc)); // the folded record must still validate; a throw keeps the record as written
|
|
116
|
+
writeAtomic(unfoldedAt, raw);
|
|
117
|
+
writeAtomic(findingsPath, `${JSON.stringify(doc, null, 2)}\n`);
|
|
118
|
+
folded = { map, merges };
|
|
119
|
+
writeFold(dir, { seamPassed: true, map, merges });
|
|
120
|
+
return { merges };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// A fold that cannot be made leaves the record as the save wrote it, and the state must say so: a map
|
|
123
|
+
// and a kept record from an earlier fold would send every reader of the model's numbering to a record
|
|
124
|
+
// this save has replaced. The failure is recorded, because nothing else reads this function's return.
|
|
125
|
+
const error = String(e?.message ?? e).replace(/\s+/g, " ").slice(0, 160);
|
|
126
|
+
try {
|
|
127
|
+
const seamPassed = seam || state?.seamPassed === true;
|
|
128
|
+
if (folded) writeFold(dir, { seamPassed, ...folded, error });
|
|
129
|
+
else { rmSync(unfoldedAt, { force: true }); writeFold(dir, { seamPassed, error }); }
|
|
130
|
+
} catch { /* best-effort */ }
|
|
131
|
+
return { merges: [], error };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The fold an accepted save makes, with the run's frozen framework manifest. Never throws. */
|
|
136
|
+
export function foldAfterSave(runDir, findingsPath) {
|
|
137
|
+
let manifest = null;
|
|
138
|
+
try { manifest = parseFrameworkManifest(readFileSync(driverDir(String(runDir ?? ""), "framework.json"), "utf8")); }
|
|
139
|
+
catch { manifest = null; } // a run with no frozen manifest folds as the seam does for it
|
|
140
|
+
return foldFindingsFile(runDir, findingsPath, manifest);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A pass opens before the seam. The map stands: the file on disk is still what the last writer left. */
|
|
144
|
+
export function openPass(runDir) {
|
|
145
|
+
const state = readFold(runDir);
|
|
146
|
+
if (!state?.seamPassed) return;
|
|
147
|
+
try { writeFold(runDir, { ...state, seamPassed: false }); } catch { /* best-effort: the seam rewrites it */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The record in the model's numbering: the one the last save wrote while a fold is in force, else findings.json. */
|
|
151
|
+
export function modelRecordPath(runDir, findingsPath) {
|
|
152
|
+
const at = driverDir(String(runDir ?? ""), UNFOLDED_FILE);
|
|
153
|
+
return readFold(runDir)?.map && existsSync(at) ? at : findingsPath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The fold as it stands, to put back if a pass that changed it is rolled back. Never throws. */
|
|
157
|
+
export function snapshotFold(runDir) {
|
|
158
|
+
const state = readFold(runDir);
|
|
159
|
+
let unfolded = null;
|
|
160
|
+
try { if (state?.map) unfolded = readFileSync(driverDir(String(runDir ?? ""), UNFOLDED_FILE), "utf8"); } catch { unfolded = null; }
|
|
161
|
+
return { state, unfolded };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Put a snapshot's fold back beside the findings.json it described. This pass's seam stays as it is. */
|
|
165
|
+
export function restoreFold(runDir, snap) {
|
|
166
|
+
const dir = String(runDir ?? "");
|
|
167
|
+
try {
|
|
168
|
+
const seamPassed = readFold(dir)?.seamPassed === true;
|
|
169
|
+
if (snap?.state?.map && snap.unfolded != null) {
|
|
170
|
+
writeAtomic(driverDir(dir, UNFOLDED_FILE), snap.unfolded);
|
|
171
|
+
writeFold(dir, { seamPassed, map: snap.state.map, merges: snap.state.merges });
|
|
172
|
+
} else {
|
|
173
|
+
rmSync(driverDir(dir, UNFOLDED_FILE), { force: true });
|
|
174
|
+
if (readFold(dir)) writeFold(dir, { seamPassed });
|
|
175
|
+
}
|
|
176
|
+
} catch { /* best-effort: the next save or seam rewrites the state */ }
|
|
177
|
+
}
|
|
@@ -90,12 +90,12 @@
|
|
|
90
90
|
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
91
91
|
import { join } from "node:path";
|
|
92
92
|
|
|
93
|
-
import { kebab } from "./search-policy.mjs";
|
|
93
|
+
import { kebab } from "./search-policy.mjs"; import { moreThan } from "../shared/register-floor.mjs";
|
|
94
94
|
import { resolveRegions } from "./register-plan.mjs";
|
|
95
95
|
import { reachableRegions } from "./register-availability.mjs"; // — pure; the env binding is injected
|
|
96
96
|
import { variantForms, VARIANT_RULES, VARIANT_CAP } from "./register-variants.mjs";
|
|
97
97
|
import { isCapabilityGap } from "../providers/_shared/execute-plan.mjs";
|
|
98
|
-
import { plainDeferralReason } from "./deferral-row.mjs"; // — the reader's line for a count left open
|
|
98
|
+
import { plainDeferralReason, plainCause } from "./deferral-row.mjs"; // — the reader's line for a count left open
|
|
99
99
|
|
|
100
100
|
/** The three questions, in report order. `matchMode` is the provider-neutral predicate name.
|
|
101
101
|
*
|
|
@@ -592,7 +592,24 @@ export function disclosedFloor(c) {
|
|
|
592
592
|
}
|
|
593
593
|
|
|
594
594
|
/** A floor as every page prints it: the register's own figure, and no sentence around it. */
|
|
595
|
-
export
|
|
595
|
+
export { moreThan } from "../shared/register-floor.mjs";
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* THE READER'S LINE FOR A COUNT THAT DID NOT LAND. A count's `unavailable` text is the counts record's: it
|
|
599
|
+
* can carry the provider's own refusal, or an exception's message (`count threw: …`), and that text
|
|
600
|
+
* belongs to the record, never to a cell a client reads. Two lines the counter writes for a reader stay as
|
|
601
|
+
* they are: a name with no near-form, and how many of the close-variation forms went uncounted (without the
|
|
602
|
+
* raw causes that follow it). Anything else reads as the shipped line for a count left open
|
|
603
|
+
* (deferral-row.mjs, plainCause). `null` when the cell has no text. PURE.
|
|
604
|
+
*/
|
|
605
|
+
export function countNote(cell) {
|
|
606
|
+
const t = String(cell?.unavailable ?? "").trim();
|
|
607
|
+
if (!t) return null;
|
|
608
|
+
if (/^no close-variation forms could be generated from ".*" — the name has no near-form under any rule in the table, so there was nothing to count$/.test(t)) return t;
|
|
609
|
+
const partial = /^\d+ of \d+ variant form\(s\) could not be counted, so the total would understate(?= — |$)/.exec(t);
|
|
610
|
+
if (partial) return partial[0];
|
|
611
|
+
return plainCause(t);
|
|
612
|
+
}
|
|
596
613
|
|
|
597
614
|
export function countLine(entry) {
|
|
598
615
|
if (!entry?.counts) return null;
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// ── AND WHY IT DELEGATES TO resolveRegions RATHER THAN RE-IMPLEMENTING IT ───────────────────────────
|
|
33
33
|
//
|
|
34
34
|
// Five vocabularies sit between a display name and a covered office: display → `normalizeTerritory` →
|
|
35
|
-
// `canonicalJurisdictionCode` → `offices.translate` → `covered.has`.
|
|
35
|
+
// `canonicalJurisdictionCode` → `offices.translate` → `covered.has`. resolveRegions() in register-plan.mjs records
|
|
36
36
|
// the copper-bastion incident from re-implementing that chain — the composer submitted display names,
|
|
37
37
|
// corsearch's ISO-passthrough translate sent them to the wire verbatim, the vendor answered 500 rather
|
|
38
38
|
// than 400, and recovery burned its park budget re-sending a query that could never succeed.
|
package/driver/register-plan.mjs
CHANGED
|
@@ -366,6 +366,20 @@ export const planMaxOrWidth = (capabilities) =>
|
|
|
366
366
|
Number.isFinite(capabilities?.maxOrWidth) && capabilities.maxOrWidth >= 1
|
|
367
367
|
? Math.floor(capabilities.maxOrWidth) : PLAN_MAX_OR_WIDTH;
|
|
368
368
|
|
|
369
|
+
/**
|
|
370
|
+
* The OR-stack width for an EXACT spelling band. A provider whose general width is narrower than what it
|
|
371
|
+
* can take for exact spellings declares `exactOrWidth` (signa: one term per request in general, up to 100
|
|
372
|
+
* exact spellings as one ranked list); every other provider stacks exact spellings at its general width.
|
|
373
|
+
*/
|
|
374
|
+
export const planExactOrWidth = (capabilities) =>
|
|
375
|
+
Number.isFinite(capabilities?.exactOrWidth) && capabilities.exactOrWidth >= 1
|
|
376
|
+
? Math.floor(capabilities.exactOrWidth) : planMaxOrWidth(capabilities);
|
|
377
|
+
|
|
378
|
+
/** The OR-stack width for a SOUND-ALIKE band, by the same rule: `phoneticOrWidth` where a provider declares one. */
|
|
379
|
+
export const planPhoneticOrWidth = (capabilities) =>
|
|
380
|
+
Number.isFinite(capabilities?.phoneticOrWidth) && capabilities.phoneticOrWidth >= 1
|
|
381
|
+
? Math.floor(capabilities.phoneticOrWidth) : planMaxOrWidth(capabilities);
|
|
382
|
+
|
|
369
383
|
// The plan emits ONE `wildcard` predicate; the provider contract splits it into three sub-capabilities.
|
|
370
384
|
// Mirrors the executor's planPredicateParams anchoring exactly (trailing * → prefix/starts-with,
|
|
371
385
|
// leading * → suffix/ends-with, both/neither → infix over the raw pattern).
|
|
@@ -1111,26 +1125,31 @@ export function compileRegisterPlan({ manifest, job, form = null, skillVersion =
|
|
|
1111
1125
|
// anywhere: the exact silent-wrong-query false-clean class this carriage fix exists to kill,
|
|
1112
1126
|
// quieter than the loud deferral it replaced. formKey now strips accents from LATIN bases only
|
|
1113
1127
|
// and preserves every non-Latin combining mark (width still folds via NFKD).
|
|
1114
|
-
// - and
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1128
|
+
// - and two variants that share a key while dictating DIFFERENT romanisations carry EVERY one of
|
|
1129
|
+
// them, on every entry for that key. One string of characters can have two readings: the same
|
|
1130
|
+
// Han characters are read one way in Chinese and another in Japanese, and the manifest lists the
|
|
1131
|
+
// string once per reading. This rule used to read that as the model contradicting itself and
|
|
1132
|
+
// compile the key bare, so the romanisation-index provider refused it and the slice reached the
|
|
1133
|
+
// client as "not searched" — while the model, seeing the refusal, searched both readings as
|
|
1134
|
+
// supplementals and got its answer (a delivered report, 2026-10-08). Carrying every reading still never
|
|
1135
|
+
// resolves by position, and never sends a sibling's form in place of the dictated one: each
|
|
1136
|
+
// reading is asked, in the same request, and a reading that is simply wrong costs one spelling.
|
|
1118
1137
|
const romanByValue = new Map();
|
|
1119
1138
|
for (const v of manifest.variants) {
|
|
1120
1139
|
if (!v.romanization) continue;
|
|
1121
1140
|
const key = formKey(v.value);
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1141
|
+
const readings = romanByValue.get(key) ?? [];
|
|
1142
|
+
const reading = romanizationSpellings(v.romanization)[0];
|
|
1143
|
+
if (reading && !readings.some((r) => romanizationSpellings(r)[0].toUpperCase() === reading.toUpperCase()))
|
|
1144
|
+
readings.push(v.romanization);
|
|
1145
|
+
romanByValue.set(key, readings);
|
|
1126
1146
|
}
|
|
1127
1147
|
const romanStamp = (e) => {
|
|
1128
1148
|
// An owner NAME is not mark text and rides its own field (the executor drops the carrier on an
|
|
1129
1149
|
// owner query anyway). An OR-stack is chunked, not substituted — one non-Latin member must never
|
|
1130
1150
|
// silently replace a whole chunk's names, so a `terms[]` entry never carries the field.
|
|
1131
1151
|
if (e.predicate === "owner" || Array.isArray(e.terms)) return {};
|
|
1132
|
-
const
|
|
1133
|
-
const spellings = roman ? romanizationSpellings(roman) : [];
|
|
1152
|
+
const spellings = [...new Set((romanByValue.get(formKey(e.term)) ?? []).flatMap(romanizationSpellings))];
|
|
1134
1153
|
return spellings.length ? { romanizedTerms: spellings } : {};
|
|
1135
1154
|
};
|
|
1136
1155
|
|
|
@@ -1266,7 +1285,7 @@ export function compileRegisterPlan({ manifest, job, form = null, skillVersion =
|
|
|
1266
1285
|
// this compiler's own equivalence: `norm` and `formKey` both strip separators, so "BIO VELTRIS",
|
|
1267
1286
|
// "BIOVELTRIS", "BIO-VELTRIS" and "BIO.VELTRIS" are ONE key. The variant loop below already drops a
|
|
1268
1287
|
// model variant that restates the mark that way, on exactly that test. Dispatching the generated set
|
|
1269
|
-
// would send the mark's own exact entry three more times under a different spelling — the
|
|
1288
|
+
// would send the mark's own exact entry three more times under a different spelling — the Drivers Haven
|
|
1270
1289
|
// shape (five diacritic terms, same 424 hits, the run's record budget gone), re-imported. The family
|
|
1271
1290
|
// is enumerated and marked in form-neighbourhood.json because it is genuinely mechanical and the
|
|
1272
1291
|
// surfaces that do NOT fold spacing are real; wiring it to those is not this change.
|
|
@@ -1472,8 +1491,11 @@ export function compileRegisterPlan({ manifest, job, form = null, skillVersion =
|
|
|
1472
1491
|
const formTerms = [...formBand.exactQueries];
|
|
1473
1492
|
const latinFormTerms = formTerms.filter((t) => !isNonLatinTerm(t));
|
|
1474
1493
|
const nativeFormTerms = formTerms.filter((t) => isNonLatinTerm(t));
|
|
1475
|
-
|
|
1476
|
-
|
|
1494
|
+
// An exact band stacks at the provider's EXACT width, which is wider than its general one where the
|
|
1495
|
+
// register takes a list of exact spellings in one request (planExactOrWidth).
|
|
1496
|
+
const exactWidth = planExactOrWidth(caps);
|
|
1497
|
+
for (let i = 0; i < latinFormTerms.length; i += exactWidth)
|
|
1498
|
+
push({ axis: "primary-sweep", predicate: "exact", terms: latinFormTerms.slice(i, i + exactWidth), expected_kind: "enumerate", provenance: "floor", qidSuffix: "+form" });
|
|
1477
1499
|
for (const t of nativeFormTerms)
|
|
1478
1500
|
push({ axis: "primary-sweep", predicate: "exact", term: t, expected_kind: "enumerate", provenance: "floor", qidSuffix: "+form" });
|
|
1479
1501
|
}
|
|
@@ -2173,7 +2195,10 @@ export function validatePlanFeasibility(plan, { capabilities = null, maxOrWidth
|
|
|
2173
2195
|
if (!PLAN_PREDICATES.includes(e?.predicate)) add("unexecutable", `unknown predicate "${e?.predicate}"`);
|
|
2174
2196
|
const names = Array.isArray(e?.terms) ? e.terms : e?.term != null ? [e.term] : [];
|
|
2175
2197
|
if (!names.length || names.some((t) => !String(t ?? "").trim())) add("unexecutable", "empty term(s)");
|
|
2176
|
-
|
|
2198
|
+
const width = !capabilities ? maxOrWidth
|
|
2199
|
+
: e?.predicate === "exact" ? Math.max(maxOrWidth, planExactOrWidth(capabilities))
|
|
2200
|
+
: e?.predicate === "phonetic" ? Math.max(maxOrWidth, planPhoneticOrWidth(capabilities)) : maxOrWidth;
|
|
2201
|
+
if (Array.isArray(e?.terms) && e.terms.length > width) add("repairable", `OR-stack of ${e.terms.length} names exceeds the executor bound (${width}) — the executor runs it chunked`);
|
|
2177
2202
|
for (const t of names) if (String(t).length > maxNameLength) add("repairable", `name exceeds ${maxNameLength} chars ("${String(t).slice(0, 40)}…") — provider-side truncation risk only`);
|
|
2178
2203
|
if ((e?.nice_classes ?? []).some((c) => !Number.isFinite(Number(c)))) add("unexecutable", "non-numeric nice_class");
|
|
2179
2204
|
if (e?.when && guardParentQid(e.when) != null && !qids.has(guardParentQid(e.when))) add("unexecutable", `when-guard targets unknown qid "${guardParentQid(e.when)}"`);
|
|
@@ -2359,7 +2384,10 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
|
|
|
2359
2384
|
// and the report's coverage line claims to: it reads "skipped after a broader search came back
|
|
2360
2385
|
// crowded", which is true when the parent was `incomplete` and false when the parent FAILED or
|
|
2361
2386
|
// never ran. The state that decides it is read one line above and was thrown away here.
|
|
2362
|
-
|
|
2387
|
+
// AND WHETHER IT ERRED, because the state cannot say: a band has two states, and the executor writes
|
|
2388
|
+
// every provider error as `incomplete` with `error: true` beside it. Read off the state alone, a
|
|
2389
|
+
// broader search that failed was a broader search that crowded.
|
|
2390
|
+
if (parentState !== "enumerated") { skipped.push({ qid: e.qid, guard: e.when.runs_if_enumerated, parent_state: parentState || null, ...(parent?.error === true ? { parent_error: true } : {}) }); continue; }
|
|
2363
2391
|
}
|
|
2364
2392
|
}
|
|
2365
2393
|
const b = byQid.get(e.qid);
|
|
@@ -2404,6 +2432,16 @@ export function joinPlanToBands(plan, bandBlocksByAxis, { released = new Set() }
|
|
|
2404
2432
|
state: String(b.state ?? "").toLowerCase(),
|
|
2405
2433
|
records: recs,
|
|
2406
2434
|
total_hits: Number.isFinite(hits) ? hits : null,
|
|
2435
|
+
// A SLICE THAT RAN AND COULD NOT BE ANSWERED. The executor stamps `deferred: true` with no error on an
|
|
2436
|
+
// owner sweep whose name the register never resolved: the call ran, so it is executed, and the band
|
|
2437
|
+
// says `incomplete` with no count. The deferral is the only thing that tells it from a listing that
|
|
2438
|
+
// overflowed, so it rides with the row rather than stopping at the block.
|
|
2439
|
+
...(b.deferred === true ? { deferred: true } : {}),
|
|
2440
|
+
// WHAT THE TOTAL COUNTS, when the register said: `records`, one per country a mark covers. Every
|
|
2441
|
+
// reader of this row reads the count as the register's own number (ruled 2026-10-02).
|
|
2442
|
+
...(typeof b.total_counts === "string" ? { total_counts: b.total_counts } : {}),
|
|
2443
|
+
// The register's floor when it answered "at least N" instead of a count: total_hits stays null beside it.
|
|
2444
|
+
...(Number.isFinite(b.total_floor) ? { total_floor: b.total_floor } : {}),
|
|
2407
2445
|
});
|
|
2408
2446
|
}
|
|
2409
2447
|
const planQids = new Set(plan.entries.map((e) => e.qid));
|
|
@@ -2613,9 +2651,30 @@ export function deriveCoverageSkeleton(plan, join) {
|
|
|
2613
2651
|
const deferredQids = new Set((join.deferred ?? []).map((x) => x.qid));
|
|
2614
2652
|
const awaitingQids = new Set((join.awaiting ?? []).map((x) => x.qid));
|
|
2615
2653
|
const askedQids = new Set((join.asked ?? []).map((x) => x.qid));
|
|
2654
|
+
// ── A COUNT THE PLAN ALSO READ IN FULL IS NOT A CROWD ──────────────────────────────────────────
|
|
2655
|
+
// A count-only descriptor (the saturation probe's shape) sizes a question and reads none of its
|
|
2656
|
+
// records, so it lands `incomplete` and its axis reads as a crowd. Where another entry asked the
|
|
2657
|
+
// IDENTICAL question — predicate, term, classes, territories, owner and goods — and listed every
|
|
2658
|
+
// record of the same total, nothing the count describes is unread. Leaving the axis `incomplete` told
|
|
2659
|
+
// the reading seat a set was unreviewed that had been read in full, and a delivered report made its
|
|
2660
|
+
// verdict conditional on reviewing it (2026-10-08). Anything short of that stays a crowd.
|
|
2661
|
+
const entryByQid = new Map(plan.entries.map((e) => [e.qid, e]));
|
|
2662
|
+
const question = (e) => (!e || Array.isArray(e.terms) ? null : JSON.stringify([
|
|
2663
|
+
String(e.predicate ?? ""), formKey(String(e.term ?? "")),
|
|
2664
|
+
[...(e.nice_classes ?? [])].map(String).sort(), [...(e.regions ?? [])].map(String).sort(),
|
|
2665
|
+
String(e.owner ?? ""), goodsTermsList(e)]));
|
|
2666
|
+
const readInFull = new Map();
|
|
2667
|
+
for (const x of join.executed) {
|
|
2668
|
+
if (x.state !== "enumerated" || !Number.isInteger(x.total_hits) || x.records !== x.total_hits) continue;
|
|
2669
|
+
const q = question(entryByQid.get(x.qid));
|
|
2670
|
+
if (q) readInFull.set(q, x.total_hits);
|
|
2671
|
+
}
|
|
2672
|
+
const totalByQid = new Map(join.executed.map((x) => [x.qid, x.total_hits]));
|
|
2673
|
+
const readElsewhere = (e) => e.expected_kind === "count" && Number.isInteger(totalByQid.get(e.qid))
|
|
2674
|
+
&& readInFull.get(question(e)) === totalByQid.get(e.qid);
|
|
2616
2675
|
const axes = new Map();
|
|
2617
2676
|
for (const e of plan.entries) {
|
|
2618
|
-
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 });
|
|
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 });
|
|
2619
2678
|
const a = axes.get(e.axis);
|
|
2620
2679
|
a.entries++;
|
|
2621
2680
|
// COUNTED BEFORE `skipped`, and separately from it. With the families waiting on every matter this is the ordinary state of
|
|
@@ -2631,7 +2690,10 @@ export function deriveCoverageSkeleton(plan, join) {
|
|
|
2631
2690
|
// a capability gap is NOT executed — it is a disclosed hole in the axis
|
|
2632
2691
|
if (deferredQids.has(e.qid)) { a.deferred.push(e.qid); continue; }
|
|
2633
2692
|
a.executed++;
|
|
2634
|
-
if (stateByQid.get(e.qid) === "incomplete")
|
|
2693
|
+
if (stateByQid.get(e.qid) === "incomplete") {
|
|
2694
|
+
if (readElsewhere(e)) a.readElsewhere++;
|
|
2695
|
+
else a.crowds++;
|
|
2696
|
+
}
|
|
2635
2697
|
}
|
|
2636
2698
|
return [...axes.values()].map((a) => ({
|
|
2637
2699
|
axis: a.axis,
|
|
@@ -2655,6 +2717,7 @@ export function deriveCoverageSkeleton(plan, join) {
|
|
|
2655
2717
|
entries: a.entries, executed: a.executed, crowds: a.crowds, skipped: a.skipped, missing: a.missing,
|
|
2656
2718
|
...(a.awaiting ? { awaiting: a.awaiting } : {}),
|
|
2657
2719
|
...(a.asked ? { asked: a.asked } : {}),
|
|
2720
|
+
...(a.readElsewhere ? { read_elsewhere: a.readElsewhere } : {}),
|
|
2658
2721
|
...(a.deferred.length ? { deferred: a.deferred } : {}),
|
|
2659
2722
|
}));
|
|
2660
2723
|
}
|
|
@@ -2777,8 +2840,10 @@ export function openBlocksByAxis(skeleton, bandBlocksByAxis, plan) {
|
|
|
2777
2840
|
// under the deleted prose join (the qid, or this number standalone). Both are now written INTO the
|
|
2778
2841
|
// form's row by the driver, so the equivalence the join had to test for is structural — see
|
|
2779
2842
|
// coverage-form.mjs. The field stays because the row, the render and the failure token all carry it.
|
|
2843
|
+
// A null total stays off the row: `Number(null)` is 0, which printed "(0 counted)" for a register's floor
|
|
2844
|
+
// ("at least 10,000") and for a count the register refused to give.
|
|
2780
2845
|
unverified.push({ qid: e.qid,
|
|
2781
|
-
...(Number.isInteger(Number(b.total_hits)) ? { total_hits: Number(b.total_hits) } : {}),
|
|
2846
|
+
...(b.total_hits != null && Number.isInteger(Number(b.total_hits)) ? { total_hits: Number(b.total_hits) } : {}),
|
|
2782
2847
|
...(unaccounted.length ? { unaccounted: unaccounted.slice(0, 8) } : {}),
|
|
2783
2848
|
...(unaccountedClasses.length ? { unaccounted_classes: unaccountedClasses.slice(0, 8) } : {}) });
|
|
2784
2849
|
}
|
|
@@ -149,6 +149,9 @@ function toRecord(row, { term, basis, provider }) {
|
|
|
149
149
|
registrationNumber: row?.registration_number ?? null,
|
|
150
150
|
irNumber: row?.ir_number ?? null,
|
|
151
151
|
filingRoute: row?.filing_route ?? null,
|
|
152
|
+
// The office's page as the register gave it, where it gave one; publish uses it only when the numbers
|
|
153
|
+
// above do not address the office's page.
|
|
154
|
+
officeUrl: row?.office_url ?? null,
|
|
152
155
|
// WHICH QUESTION FOUND IT. Without this a reader cannot tell a filing on the name from a filing on
|
|
153
156
|
// a generated variant, and the two mean very different things to the person deciding on the name.
|
|
154
157
|
matchedForm: term,
|
|
@@ -248,6 +251,7 @@ export async function listRegisterRecords({
|
|
|
248
251
|
classes: scoped, regions, provider, ok, requested: want, fetched,
|
|
249
252
|
total: Number.isFinite(r?.total) ? r.total : null, took_ms: Date.now() - started,
|
|
250
253
|
...(ok && r?.approximate === true ? { approximate: true, floor: Number.isFinite(r?.floor) ? r.floor : null } : {}),
|
|
254
|
+
...(ok && Array.isArray(r?.warnings) && r.warnings.length ? { warnings: r.warnings } : {}),
|
|
251
255
|
...(ok ? {} : { cause: String(r?.reason ?? "unknown").slice(0, 300) }),
|
|
252
256
|
}) + "\n");
|
|
253
257
|
} catch { /* receipts are best-effort, never fatal */ }
|
|
@@ -260,6 +264,9 @@ export async function listRegisterRecords({
|
|
|
260
264
|
// An approximation is carried as one, exactly as the count lane records it: no number in
|
|
261
265
|
// `total`, the register's floor beside it. Only a provider whose listing states it sets it.
|
|
262
266
|
...(ok && r?.approximate === true ? { approximate: true, floor: Number.isFinite(r?.floor) ? r.floor : null } : {}),
|
|
267
|
+
// The register's own notes on this answer, as it sent them — absent when it sent none, which is
|
|
268
|
+
// not evidence that the answer carried no caveat on a register that sends none at all.
|
|
269
|
+
...(ok && Array.isArray(r?.warnings) && r.warnings.length ? { warnings: r.warnings } : {}),
|
|
263
270
|
...(ok ? {} : { reason: String(r?.reason ?? "the filings could not be fetched").slice(0, 300) }),
|
|
264
271
|
};
|
|
265
272
|
});
|