clearotron 0.4.1-beta.4 → 0.4.1-beta.6
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/.env.example +7 -0
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +1 -0
- package/driver/CHANGELOG.md +16 -0
- package/driver/ask-ledger.mjs +6 -1
- package/driver/band-shape.mjs +398 -21
- package/driver/clearance-variants-record.mjs +5 -1
- package/driver/close-verify.mjs +27 -8
- package/driver/contract-vocabulary.mjs +2 -0
- package/driver/degraded-parts.mjs +7 -0
- package/driver/effective-scope.mjs +27 -1
- package/driver/engine/anthropic-agent.mjs +7 -3
- package/driver/engine/common.mjs +10 -3
- package/driver/engine/mcp/band-server.mjs +179 -36
- package/driver/engine/mcp/codex-config.mjs +24 -4
- package/driver/engine/mcp/gather-config.mjs +4 -3
- package/driver/engine/mcp/recording-server.mjs +8 -0
- package/driver/engine/openai-agent.mjs +87 -2
- package/driver/findings-model.mjs +7 -3
- package/driver/gateway.mjs +36 -10
- package/driver/matter-frame-record.mjs +12 -2
- package/driver/package.json +1 -1
- package/driver/partial-payload-baseline.json +2 -1
- package/driver/pipeline.mjs +129 -37
- package/driver/placement-diff.mjs +8 -3
- package/driver/placement-form-io.mjs +31 -0
- package/driver/placement-form.mjs +231 -31
- package/driver/placement-model.mjs +6 -0
- package/driver/placement-union.mjs +134 -10
- package/driver/predelivery-lint.mjs +3 -3
- package/driver/publish/index.mjs +4 -1
- package/driver/record-goods.mjs +291 -0
- package/driver/remedy-accounting.mjs +2 -6
- package/driver/skills/clearance-variants/SKILL.md +2 -2
- package/driver/skills/knockout-assess/SKILL.md +2 -2
- package/driver/skills/matter-frame/SKILL.md +2 -2
- package/driver/skills/placement-inquiry/SKILL.md +17 -17
- package/driver/stage-context.mjs +6 -2
- package/driver/stages-knockout.mjs +0 -13
- package/driver/stages.mjs +19 -10
- package/driver/suite-census.json +196 -16
- package/driver/terminal-clamp.mjs +39 -5
- package/driver/unit-inventory.mjs +7 -0
- package/driver/variant-manifest-model.mjs +26 -1
- package/driver/verify-knockout.mjs +27 -10
- package/driver/verify.mjs +79 -7
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/http-smoke.mjs +2 -0
- package/mcp-server/lib/audit-view.mjs +10 -3
- package/mcp-server/lib/events.mjs +7 -0
- package/mcp-server/lib/knockout.mjs +7 -5
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +1 -0
- package/scripts/authority-boundary-probe.mjs +2 -0
- package/scripts/band-shape-probe.mjs +2 -1
- package/scripts/freeze-example-run.mjs +2 -2
- package/scripts/report-header-render-check.mjs +1 -0
- package/scripts/report-offline-render-check.mjs +1 -0
- package/scripts/report-screenshot.mjs +1 -0
- package/scripts/report-sections-render-check.mjs +1 -0
- package/scripts/report-theme-render-check.mjs +1 -0
- package/scripts/revisit-render-check.mjs +1 -0
- package/scripts/settings-render-check.mjs +1 -0
- package/scripts/undeclared-key-census.mjs +1 -0
- package/scripts/verify-publishable.mjs +2 -0
- package/shared/names-in-force.mjs +1 -0
package/.env.example
CHANGED
|
@@ -106,6 +106,13 @@ CLEAROTRON_CODEX_PATH=codex
|
|
|
106
106
|
# effect: tuning
|
|
107
107
|
CLEAROTRON_TOOL_ANSWER_BYTES=
|
|
108
108
|
|
|
109
|
+
# Runtime-injected — do NOT set this by hand. The driver sets it to 1 in the band tool server's env for the
|
|
110
|
+
# placement step alone (driver/stages.mjs GROUP_READING_STAGES): that step is offered band_lookup's group
|
|
111
|
+
# filter and reads the band shape with the orders written for it. Every other step is served both without
|
|
112
|
+
# them. Set by hand, it would offer every step the group pages, and nothing in a run records that it did.
|
|
113
|
+
# effect: silent-output-change
|
|
114
|
+
# CLEAROTRON_BAND_GROUPS=
|
|
115
|
+
|
|
109
116
|
CLEAROTRON_WORK_DIR=
|
|
110
117
|
# Customer/project config store (loadProfiles). Unset ⇒ driver's built-in profiles dir.
|
|
111
118
|
CLEAROTRON_CUSTOMERS_DIR=
|
package/build-info.json
CHANGED
|
@@ -221,6 +221,7 @@ deployment may override (verify live values per deployment).
|
|
|
221
221
|
| `CLEAROTRON_REGISTER_RECORD_LOG` | **runtime-injected per run**: `<runDir>/_driver/register-record-bodies.jsonl` | Citation-fidelity log: the BODY of every fetched official record. ** moved it INTO the run** — created with the run, unioned into the run's`_records/`, archived and purged with it. There is no retention setting and no cleanup job, because it no longer grows on the box: held globally it reached 432 MB in 61 days on production and needed a rotation timer on every install. **Do not set this by hand** — a fixed value pins every run's bodies to one file and restores the problem. A box upgraded across still holds its old global file; nothing writes or reads it, the driver names it once per process on stderr, and archiving it is one`mv`. An empty log cannot read as verified: the run's successful `record_fetch` rows in the (still global) call ledger are compared against the assembled record set, and a gap is reported as a failure. |
|
|
222
222
|
| `CORSEARCH_CALL_LOG` / `CORSEARCH_RECORD_LOG` | — | **Deprecated, honoured for one release.** These are the names these two variables carried before the rename. Unset on every deployed box (all three ran the homedir default), so what actually protects an upgrade is the filename fallback: a`corsearch-calls.jsonl` / `corsearch-records.jsonl` already on disk keeps being read where it sits. Resolution order is in `providers/_shared/ledger-path.mjs`. |
|
|
223
223
|
| `CLEAROTRON_BAND_RUN_DIR` | set per dispatch | The run dir the band MCP server writes into, injected per stage — unset means the server has no run to write to and says so rather than guessing one. |
|
|
224
|
+
| `CLEAROTRON_BAND_GROUPS` | set per dispatch: `1` for the placement step alone, unset for every other | Whether the band MCP server offers `band_lookup`'s group filter and serves the band shape with the placement step's orders under its groups. Driver-set from `GROUP_READING_STAGES` in `driver/stages.mjs`; not operator configuration. |
|
|
224
225
|
| `CLEAROTRON_FEEDBACK_DIR` | `<poolRoot>/_feedback` | Where report feedback flags are stored. Beside the pool by default so a deployment that moves the pool moves the flags with it. |
|
|
225
226
|
| `USPTO_LOCAL_DB` | **none — set it to use `uspto-local`** | The local USPTO index (`node:sqlite` + FTS5) that `bin/uspto-sync.mjs` builds and the free US register reads. Named in `.env.example`; this is the reference row. |
|
|
226
227
|
| `CLEAROTRON_JX_SUBCLASS_DB` | unset ⇒ the lane refuses by name | Path to the built similar-group database the JX subclass lane reads, produced by `providers/jx-subclass/load-public.mjs` from the committed public tables. It names WHERE the table lives; what the table says is the build's. Absence is a refusal rather than an empty answer — a clearance that silently found no similar groups is indistinguishable from one where the file was missing. |
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.4.1-beta.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fixed: A clearance no longer reports a search as missing, or lowers a clear result, when the same search had already run under another entry.
|
|
8
|
+
- Fixed: The account connector's decision timeline no longer lists the engine's internal bookkeeping events.
|
|
9
|
+
- Fixed: The account connector now lists a knockout search's review step, and no longer says the search makes two research calls per name.
|
|
10
|
+
|
|
11
|
+
## 0.4.1-beta.5
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Fixed: A report's conditions no longer include an internal note about the search's own checking steps.
|
|
16
|
+
- For operators: A stopped run no longer leaves behind a working folder linked to the Codex sign-in; leftovers are removed the next time Codex runs.
|
|
17
|
+
- Fixed: Every live identical or near-identical mark a register search finds is now placed in the report or set aside with its reason.
|
|
18
|
+
|
|
3
19
|
## 0.4.1-beta.4
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/driver/ask-ledger.mjs
CHANGED
|
@@ -341,6 +341,11 @@ export function deriveAsks({
|
|
|
341
341
|
const deferrals = new Map((fr.deferrals ?? []).filter((d) => d?.directive).map((d) => [d.directive, d]));
|
|
342
342
|
const qidsByKey = fr.directive_qids && typeof fr.directive_qids === "object" ? fr.directive_qids : {};
|
|
343
343
|
const bornByKey = fr.born && typeof fr.born === "object" ? fr.born : {};
|
|
344
|
+
// A slice the fold refused as already-asked never ran: the plan row that asked its question did. The receipt
|
|
345
|
+
// names that row where the directive closed through it (`answered_elsewhere`), and its execution ends the ask.
|
|
346
|
+
const twinOf = new Map((Array.isArray(fr.answered_elsewhere) ? fr.answered_elsewhere : [])
|
|
347
|
+
.filter((x) => typeof x?.directive === "string")
|
|
348
|
+
.map((x) => [x.directive, new Map((Array.isArray(x.slices) ? x.slices : []).filter((t) => t?.qid && t?.twin).map((t) => [t.qid, t.twin]))]));
|
|
344
349
|
for (const key of requested) {
|
|
345
350
|
const qids = (Array.isArray(qidsByKey[key]) ? qidsByKey[key] : []).filter(Boolean);
|
|
346
351
|
const place = bornByKey[key] === "form-neighbourhood" ? "form-neighbourhood" : "frame-diff";
|
|
@@ -351,7 +356,7 @@ export function deriveAsks({
|
|
|
351
356
|
handoff = "open coverage row (frame-gap) + the CLEAR→CONDITIONAL clamp — the report states the unswept omission";
|
|
352
357
|
} else if (swept.has(key)) {
|
|
353
358
|
ending = qids.length
|
|
354
|
-
? endingForQids(qids, join, ts) // computed — null (open) when the execution record disagrees
|
|
359
|
+
? endingForQids(qids.map((q) => twinOf.get(key)?.get(q) ?? q), join, ts) // computed — null (open) when the execution record disagrees
|
|
355
360
|
: mkEnding("executed", "frame-reopen-receipt", { evidence: "swept per the frame-reopen receipt (warm-resume arm — axis-level verification)", ts });
|
|
356
361
|
}
|
|
357
362
|
push({
|
package/driver/band-shape.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// The 2026-07-28 postmortem's lesson: the merged band is megabytes (median 5.2MB across the archive; Read hard-refuses
|
|
6
6
|
// >256KB), so judgment never read it whole — every stage sliced it with improvised, unreviewed shell
|
|
7
7
|
// (digest#2: 71 ad-hoc Bash calls). This module is the reading layer's frozen-plan equivalent: a PURE,
|
|
8
|
-
// deterministic projection of the band
|
|
9
|
-
//
|
|
8
|
+
// deterministic projection of the band, served in parts once it outgrows one answer (the groups section grows
|
|
9
|
+
// with the band; the parts banner says how many), derived by the driver after EVERY named-band re-merge.
|
|
10
10
|
//
|
|
11
11
|
// What it is NOT: a relevance/sufficiency/materiality judgment. Tiers are MECHANICAL string classes
|
|
12
12
|
// (the same pure utils the form floor is built from — normalizers + doubleMetaphone); whether a
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
import { normalizeElement, editNeighbourhood, consonantSkeleton, confusableSkeleton, foldDiacritics } from "./form-neighbourhood.mjs";
|
|
27
27
|
import { doubleMetaphone } from "./phonetic-key.mjs";
|
|
28
28
|
import { probeOrder } from "./order-probe.mjs"; // item 11 — inert unless CLEAROTRON_ORDER_PROBE_SEED is set
|
|
29
|
+
import { normKey } from "./placement-diff.mjs";
|
|
30
|
+
import { canonicalJurisdictionCode, KNOWN_JURISDICTION_CODES } from "./jurisdiction-codes.mjs"; // — one spelling per register: a band's "EM" and a market's "EU" are one office
|
|
31
|
+
import { bindingLayersFor } from "./binding-layers.mjs";
|
|
32
|
+
import { territoryKey } from "./territory-tiers.mjs"; // — a territory written as a name reads as its code // — the registers that bind an ordered territory: its own, the EU's for a member, every member's for the EU, Benelux, Madrid // — the set-aside key's owner fold: an owner is grouped the way a set-aside row covers it
|
|
29
33
|
|
|
30
34
|
export const BAND_SHAPE_SCHEMA_VERSION = 1;
|
|
31
35
|
|
|
@@ -206,9 +210,40 @@ export function classifyRecord(markText, targets) {
|
|
|
206
210
|
if (cons && cons === t.cons) return { tier: "same-family", target: t.text, basis: "consonant-skeleton" };
|
|
207
211
|
if (meta.length && t.metaphone.length && meta.some((m) => t.metaphone.includes(m))) return { tier: "same-family", target: t.text, basis: "double-metaphone" };
|
|
208
212
|
}
|
|
213
|
+
// ── A MARK TWO EDITS FROM A VARIANT OF SIX LETTERS OR MORE IS OF ITS FAMILY (ruled 2026-10-13) ────────
|
|
214
|
+
//
|
|
215
|
+
// The variants stage renders the mark's meaning in some of a market's languages, and the same word in another
|
|
216
|
+
// language is often two edits from one of them. None of the rungs above reads that: one edit, a whole matching
|
|
217
|
+
// word, the consonant skeleton, the sound code. Measured on a saved band, a register entry a reviewing lawyer
|
|
218
|
+
// listed sat two edits from such a meaning form and read as "other". Each pair is compared, never enumerated:
|
|
219
|
+
// the form floor's radius stays one edit (form-neighbourhood.mjs). The rung makes no floor, which takes the
|
|
220
|
+
// identical and near-identical tiers only, and runs last, so no tier above it moves.
|
|
221
|
+
for (const t of prepared) {
|
|
222
|
+
if (t.norm.length >= EDIT2_MIN_LEN && withinTwoEdits(norm, t.norm)) return { tier: "same-family", target: t.text, basis: "edit-2" };
|
|
223
|
+
}
|
|
209
224
|
return { tier: "other", target: null, basis: "no-mechanical-match" };
|
|
210
225
|
}
|
|
211
226
|
|
|
227
|
+
/** The shortest target the two-edit rung reads: below six letters two edits reach too many unrelated words. */
|
|
228
|
+
export const EDIT2_MIN_LEN = 6;
|
|
229
|
+
|
|
230
|
+
/** Is `a` within two insertions, deletions or substitutions of `b`? Stops at the first row past two. PURE. */
|
|
231
|
+
function withinTwoEdits(a, b) {
|
|
232
|
+
if (Math.abs(a.length - b.length) > 2) return false;
|
|
233
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
234
|
+
for (let i = 1; i <= a.length; i++) {
|
|
235
|
+
const cur = [i];
|
|
236
|
+
let best = i;
|
|
237
|
+
for (let j = 1; j <= b.length; j++) {
|
|
238
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
239
|
+
if (cur[j] < best) best = cur[j];
|
|
240
|
+
}
|
|
241
|
+
if (best > 2) return false;
|
|
242
|
+
prev = cur;
|
|
243
|
+
}
|
|
244
|
+
return prev[b.length] <= 2;
|
|
245
|
+
}
|
|
246
|
+
|
|
212
247
|
// ── dominant-element composites (P2-A, the recall spine) ────────────────────────────────────────────
|
|
213
248
|
// The candidate set of the retrieved→judgment reconciliation: every LIVE, in-scope, SCREEN-SURFACED
|
|
214
249
|
// record whose mark carries the manifest's dominant element — as a standalone token
|
|
@@ -258,7 +293,7 @@ export function dominantElementBasis(markText, prep) {
|
|
|
258
293
|
export function dominantElementComposites(band, { dominantElement, inScopeClasses = [] } = {}) {
|
|
259
294
|
const prep = prepareDominantElement(dominantElement);
|
|
260
295
|
if (!prep) return null; // no comparable dominant element ⇒ not computable
|
|
261
|
-
const scope = new Set((inScopeClasses ?? []).map(
|
|
296
|
+
const scope = new Set((inScopeClasses ?? []).map(niceClassKey).filter(Boolean));
|
|
262
297
|
const inScope = (r) => {
|
|
263
298
|
const cls = classesOf(r);
|
|
264
299
|
if (!scope.size) return true;
|
|
@@ -531,7 +566,9 @@ export function deriveRegisterPositions(records, detailByUri = new Map()) {
|
|
|
531
566
|
}
|
|
532
567
|
|
|
533
568
|
const inc = (obj, key, by = 1) => { const k = String(key); obj[k] = (obj[k] ?? 0) + by; };
|
|
534
|
-
|
|
569
|
+
/** A Nice class as one spelling: "09", "9" and 9 are class 9. PURE. */
|
|
570
|
+
export const niceClassKey = (c) => { const t = String(c ?? "").trim(); return /^\d+$/.test(t) ? String(Number(t)) : t; };
|
|
571
|
+
const classesOf = (r) => (Array.isArray(r?.classes) ? r.classes : r?.classes != null ? [r.classes] : []).map(niceClassKey).filter(Boolean);
|
|
535
572
|
const registryOf = (r) => String(r?.screen?.registry ?? (Array.isArray(r?.jurisdictions) ? r.jurisdictions[0] : r?.jurisdictions) ?? "").trim() || "unknown";
|
|
536
573
|
const yearOf = (r) => {
|
|
537
574
|
const m = String(r?.application_date ?? r?.screen?.applicationDate ?? "").match(/\b(19|20)\d{2}\b/);
|
|
@@ -572,6 +609,203 @@ const floorRow = (r, cls) => ({
|
|
|
572
609
|
_query: r._query ?? null,
|
|
573
610
|
});
|
|
574
611
|
|
|
612
|
+
// ── THE BAND IN THE LAWYER'S ORDER ──────────────────────────────────────────────────────────────────
|
|
613
|
+
//
|
|
614
|
+
// The crowded-field method, as ruled 2026-09-21: identical marks in the class;
|
|
615
|
+
// identical marks for the particular goods; the dominant word for the particular goods; then the rest,
|
|
616
|
+
// counted, with its rule. "Fifty? Read them all. A few hundred? Sort by the client's goods and markets,
|
|
617
|
+
// read the near ones first, list the rest. A thousand or more? The field is crowded: read the identical
|
|
618
|
+
// and live ones for the client's goods, tell the client it is crowded, and do not write up the rest."
|
|
619
|
+
//
|
|
620
|
+
// Code sorts on fields the records carry: the mark's tier against the targets, a goods-word match, class
|
|
621
|
+
// and status. It judges nothing.
|
|
622
|
+
export const BAND_GROUPS = ["a", "b", "c", "d", "rest"];
|
|
623
|
+
// The method's "a thousand or more": a group this size is served as its count and its first page.
|
|
624
|
+
export const CROWDED_GROUP_RECORDS = 1000;
|
|
625
|
+
const TIER_RANK = { identical: 0, "near-identical": 1, "same-family": 2, other: 3, unclassifiable: 4 };
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* One record's group. PURE. `hits` is how many of the client's goods words its goods contain, or null
|
|
629
|
+
* when the run holds no goods for it: unknown passes the goods test, so a record is never set aside for
|
|
630
|
+
* what the run does not hold.
|
|
631
|
+
* (a) identical or near-identical to a target, live in the ordered classes, for the client's goods;
|
|
632
|
+
* (b) identical or near-identical to a target, in the ordered classes;
|
|
633
|
+
* (c) the same family as a target, or carrying the searched dominant word, for the client's goods;
|
|
634
|
+
* (d) every other live mark in the ordered classes, for the client's goods;
|
|
635
|
+
* the rest: everything else.
|
|
636
|
+
*/
|
|
637
|
+
export function recordGroup({ tier, live, inScope, hits, dominant = false }) {
|
|
638
|
+
const ident = tier === "identical" || tier === "near-identical";
|
|
639
|
+
const forGoods = hits !== 0;
|
|
640
|
+
// (a) reads the goods of live records in the ordered classes only (ruled 2026-10-12): a dead or
|
|
641
|
+
// out-of-class record's goods never put a mark in (a).
|
|
642
|
+
if (ident && live && inScope && forGoods) return "a";
|
|
643
|
+
if (ident && inScope) return "b";
|
|
644
|
+
if ((tier === "same-family" || dominant) && forGoods) return "c";
|
|
645
|
+
if (live && inScope && forGoods) return "d";
|
|
646
|
+
return "rest";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The band in the ruled order: ONE LINE PER MARK OF AN OWNER, in one collated list per group. PURE. The unit is
|
|
651
|
+
* the mark, not the owner: an owner shown on one line under its closest mark hid a different mark of the
|
|
652
|
+
* same owner, and a ground written about the shown mark set the other aside unseen.
|
|
653
|
+
*
|
|
654
|
+
* THE LIST IS THE ORDER'S SCOPE (ruled 2026-10-12). A worldwide order lists the world; an order naming its
|
|
655
|
+
* territories lists every register that binds them (`bindingLayersFor`): a member state's own register,
|
|
656
|
+
* the EU's and Madrid's for it, every member's for an EU order, Benelux for its three. A line held only in
|
|
657
|
+
* registers outside them goes to the rest, counted with that reason and still open to a lookup. A record
|
|
658
|
+
* whose register cannot be read stays in.
|
|
659
|
+
*
|
|
660
|
+
* "FOR THE CLIENT'S GOODS" MEANS THE PARTICULAR GOODS: the words that name the client's product, never
|
|
661
|
+
* the class words the variants stage adds for the register search. `goods.hits` and `goods.matched`
|
|
662
|
+
* answer for those words.
|
|
663
|
+
*
|
|
664
|
+
* EACH GROUP RUNS IN THE ORDER THE OWNER RULED (2026-10-12): a line with the client's goods on a live record
|
|
665
|
+
* in the ordered classes first; then more live records in those classes; then more goods words; then a
|
|
666
|
+
* client market; then more records. Code sorts on fields the records carry and judges nothing. A line
|
|
667
|
+
* carries the goods words it matched, never the goods text, which is one lookup away.
|
|
668
|
+
*
|
|
669
|
+
* A line sits in the best group its records in scope reach. `keysOf` gives a record's line and its owner's
|
|
670
|
+
* set-aside key, both read through the positions fold, so a line covers exactly what a row naming one of
|
|
671
|
+
* its records reaches. A line carries the words matched, so nothing on it is cut; the one line that cannot
|
|
672
|
+
* be judged from itself is one the goods test placed while its records carry no goods on file.
|
|
673
|
+
*
|
|
674
|
+
* @param records the band's enumerated records
|
|
675
|
+
* @param classify record → { tier, target } (the shape's own classifier over its targets)
|
|
676
|
+
* @param inScope record → boolean (the shape's own class test)
|
|
677
|
+
* @param goods { of, hits, matched } (record-goods.mjs), or null when the run holds none
|
|
678
|
+
* @param dominantTerm the word the register's goods-narrowed search asked for, or ""
|
|
679
|
+
* @param keysOf record → { line, owner }, or null
|
|
680
|
+
* @param markets the client's markets, as territory codes
|
|
681
|
+
* @param territories the order's territories, as codes (effective-scope.mjs `orderTerritories`); empty for a worldwide order
|
|
682
|
+
*/
|
|
683
|
+
/**
|
|
684
|
+
* THE REST NEVER HOLDS A FLOOR. The rest is set aside in one row, at a thousand records or more on its first
|
|
685
|
+
* page and its count, so a floor in it could be left unread. The group rule puts every floor in (a) or (b); a
|
|
686
|
+
* rest holding one is a defect here, and the shape is refused rather than served with a floor no one will
|
|
687
|
+
* read. PURE (throws).
|
|
688
|
+
*/
|
|
689
|
+
export function assertRestHoldsNoFloor(restLines) {
|
|
690
|
+
const stray = (restLines ?? []).find((l) => l?.floors > 0);
|
|
691
|
+
if (stray) throw new Error(`band shape: the rest holds a floor (${stray.record_id ?? stray.key}); a floor is listed in (a) or (b), never counted in the rest`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Every register that binds any of these territories: each territory's own, the EU's for a member, every
|
|
696
|
+
* member's for the EU, Benelux's for its three, Madrid's for all. The Benelux register is written BX or as any
|
|
697
|
+
* of its three members' codes, binds all three, and sits inside the EU. PURE.
|
|
698
|
+
*/
|
|
699
|
+
const BENELUX = ["BX", "NL", "BE", "LU"];
|
|
700
|
+
function bindingOffices(territories, code) {
|
|
701
|
+
const out = new Set();
|
|
702
|
+
for (const t of territories) {
|
|
703
|
+
out.add(t);
|
|
704
|
+
try { for (const l of bindingLayersFor(t)) out.add(code(l.office)); } catch { /* an unknown code binds itself */ }
|
|
705
|
+
}
|
|
706
|
+
if (out.has("WO")) out.add("IB"); // the International Bureau is the Madrid register by another name
|
|
707
|
+
if (BENELUX.some((c) => out.has(c))) { for (const c of BENELUX) out.add(c); out.add("EU"); out.add("WO"); out.add("IB"); }
|
|
708
|
+
return out;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export function bandGroups(records, { classify, inScope, goods = null, dominantTerm = "", keysOf = null, markets = [], territories = [] } = {}) {
|
|
712
|
+
const fold = (x) => foldDiacritics(String(x ?? "")).toLowerCase();
|
|
713
|
+
const dom = fold(dominantTerm).trim();
|
|
714
|
+
const domRe = dom ? new RegExp(`(^|[^\\p{L}\\p{N}])${dom.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\p{L}\\p{N}])`, "u") : null;
|
|
715
|
+
const domPrep = prepareDominantElement(dominantTerm); // the composites' own ladder, for a word fused into a longer one
|
|
716
|
+
// A territory or register as written, a code or a name, read as its code: "Switzerland" and "CH" are one.
|
|
717
|
+
const code = (t) => canonicalJurisdictionCode(territoryKey(String(t ?? "").trim()) || String(t ?? "").trim().toUpperCase());
|
|
718
|
+
const ordered = new Set((territories ?? []).map(code).filter(Boolean));
|
|
719
|
+
// An ordered territory the engine cannot read binds no register it knows; cutting on the others would drop
|
|
720
|
+
// records inside it unseen. So it is reported, and the list holds every register.
|
|
721
|
+
const unreadTerritories = [...ordered].filter((t) => !KNOWN_JURISDICTION_CODES.has(t));
|
|
722
|
+
const offices = unreadTerritories.length ? new Set() : bindingOffices(ordered, code);
|
|
723
|
+
// The client's markets, read the same way; a market of the EU holds a member's register too.
|
|
724
|
+
const marketsGiven = [...new Set((markets ?? []).map(code).filter(Boolean))];
|
|
725
|
+
const market = bindingOffices(new Set(marketsGiven), code);
|
|
726
|
+
// A register as a provider writes it: a code, a list of codes, or a list of {jurisdiction, …}, read by its
|
|
727
|
+
// code field alone. What cannot be read gives no code, and a record with none stays in.
|
|
728
|
+
const raw = (j) => (j == null ? [] : Array.isArray(j) ? j.flatMap(raw) : typeof j === "object"
|
|
729
|
+
? ((j.jurisdiction ?? j.code ?? j.office ?? j.country) != null ? raw(j.jurisdiction ?? j.code ?? j.office ?? j.country) : [])
|
|
730
|
+
: [j]);
|
|
731
|
+
const territoriesOf = (r) => raw(r?.jurisdictions).map(code).filter((t) => /^[A-Z]{2,5}$/.test(t));
|
|
732
|
+
const inOrder = (ts) => !offices.size || !ts.length || ts.some((t) => offices.has(t));
|
|
733
|
+
const lines = new Map();
|
|
734
|
+
for (const r of Array.isArray(records) ? records : []) {
|
|
735
|
+
const { tier } = classify(r);
|
|
736
|
+
const g = goods ? goods.of(r) : null;
|
|
737
|
+
const hits = goods && g ? goods.hits(g) : null;
|
|
738
|
+
const words = goods && g && goods.matched ? goods.matched(g) : null;
|
|
739
|
+
const live = isLiveRecord(r), s = inScope(r);
|
|
740
|
+
const ts = territoriesOf(r);
|
|
741
|
+
const outside = !inOrder(ts);
|
|
742
|
+
const dominant = Boolean(domRe && domRe.test(fold(r?.mark_text))) || dominantElementBasis(r?.mark_text, domPrep) === "concatenation";
|
|
743
|
+
const floor = (tier === "identical" || tier === "near-identical") && live && s; // the floors' own test
|
|
744
|
+
// A floor is never counted in the rest (ruled 2026-10-12): held only outside the order's territories, it
|
|
745
|
+
// is still listed, in (a) or (b), where the step reads it and may set it aside by name on its territory.
|
|
746
|
+
const group = outside && !floor ? "rest" : recordGroup({ tier, live, inScope: s, hits, dominant });
|
|
747
|
+
const rec = { r, tier, hits, words, live, inScope: s, group, outside, floor, onFile: Boolean(g), territories: ts, inMarket: ts.some((t) => market.has(t)) };
|
|
748
|
+
const keys = typeof keysOf === "function" ? keysOf(r) : null;
|
|
749
|
+
const ownerKey = keys?.owner ?? (normKey(r?.owner_name) ? `owner:${normKey(r?.owner_name)}` : `record:${String(r?.record_id ?? "").trim().toLowerCase()}`);
|
|
750
|
+
const key = keys?.line ?? `line:${ownerKey}|${normMark(r?.mark_text) || String(r?.record_id ?? "").trim().toLowerCase()}`;
|
|
751
|
+
const line = lines.get(key) ?? { key, owner_key: ownerKey, owner_name: String(r?.owner_name ?? "").trim() || null, recs: [] };
|
|
752
|
+
line.recs.push(rec);
|
|
753
|
+
lines.set(key, line);
|
|
754
|
+
}
|
|
755
|
+
const rank = (g) => BAND_GROUPS.indexOf(g);
|
|
756
|
+
const better = (x, y) => TIER_RANK[x.tier] - TIER_RANK[y.tier] || (y.hits ?? -1) - (x.hits ?? -1)
|
|
757
|
+
|| Number(y.live) - Number(x.live) || String(x.r?.record_id ?? "").localeCompare(String(y.r?.record_id ?? ""));
|
|
758
|
+
const byGroup = Object.fromEntries(BAND_GROUPS.map((g) => [g, { records: 0, set_records: 0, lines: [] }]));
|
|
759
|
+
const noGoods = [];
|
|
760
|
+
let outsideLines = 0, outsideRecords = 0;
|
|
761
|
+
for (const l of lines.values()) {
|
|
762
|
+
const group = BAND_GROUPS[Math.min(...l.recs.map((x) => rank(x.group)))];
|
|
763
|
+
const inGroup = l.recs.filter((x) => x.group === group).sort(better);
|
|
764
|
+
const best = inGroup[0];
|
|
765
|
+
// Unknown is decided on the records that put the line in its group: a line placed by a record whose goods
|
|
766
|
+
// could not be read is listed apart, whatever the line's other records carry.
|
|
767
|
+
const known = inGroup.some((x) => x.words != null);
|
|
768
|
+
const words = [...new Set(l.recs.flatMap((x) => x.words ?? []))];
|
|
769
|
+
const liveInScope = l.recs.filter((x) => x.live && x.inScope && !x.outside);
|
|
770
|
+
const row = {
|
|
771
|
+
key: l.key, owner_key: l.owner_key, owner_name: l.owner_name, mark: String(best.r?.mark_text ?? "").trim() || null, group,
|
|
772
|
+
records: l.recs.length, group_records: inGroup.length, live: l.recs.filter((x) => x.live).length,
|
|
773
|
+
live_in_scope: liveInScope.length, goods_live_in_scope: liveInScope.some((x) => (x.hits ?? 0) > 0),
|
|
774
|
+
best_tier: best.tier, goods_hits: known ? words.length : null, goods_words: known ? words : null,
|
|
775
|
+
in_market: l.recs.some((x) => x.inMarket), in_scope: l.recs.some((x) => x.inScope),
|
|
776
|
+
territories: [...new Set(l.recs.flatMap((x) => x.territories))].sort(),
|
|
777
|
+
classes: [...new Set(l.recs.flatMap((x) => classesOf(x.r)))].sort((p, q) => Number(p) - Number(q) || p.localeCompare(q)),
|
|
778
|
+
// A line the goods test could not place shows a record whose goods are on file where it has one, so
|
|
779
|
+
// `band_record` opens the record the line names; `goods_on_file` says so of that record.
|
|
780
|
+
record_id: (known ? best : (inGroup.find((x) => x.onFile) ?? best)).r?.record_id ?? null,
|
|
781
|
+
floors: l.recs.filter((x) => x.floor).length,
|
|
782
|
+
...(known ? {} : { goods_on_file: inGroup.some((x) => x.onFile) }),
|
|
783
|
+
...(offices.size && l.recs.every((x) => x.outside) ? { outside: true } : {}),
|
|
784
|
+
};
|
|
785
|
+
// A line placed in (a), (c) or (d) by the goods test, whose records carry no goods the engine can read,
|
|
786
|
+
// cannot be judged from the line: it is listed apart, opened before a fate, and no group row covers it.
|
|
787
|
+
if (!known && (group === "a" || group === "c" || group === "d")) { noGoods.push(row); continue; }
|
|
788
|
+
if (group === "rest" && l.recs.every((x) => x.outside)) { row.outside = true; outsideLines++; outsideRecords += l.recs.length; }
|
|
789
|
+
byGroup[group].records += inGroup.length;
|
|
790
|
+
byGroup[group].set_records += l.recs.length;
|
|
791
|
+
byGroup[group].lines.push(row);
|
|
792
|
+
}
|
|
793
|
+
assertRestHoldsNoFloor(byGroup.rest.lines);
|
|
794
|
+
const byName = (x, y) => String(x.owner_name ?? x.owner_key).localeCompare(String(y.owner_name ?? y.owner_key))
|
|
795
|
+
|| String(x.mark ?? "").localeCompare(String(y.mark ?? "")) || String(x.key).localeCompare(String(y.key));
|
|
796
|
+
const herOrder = (x, y) => Number(y.goods_live_in_scope) - Number(x.goods_live_in_scope) || y.live_in_scope - x.live_in_scope
|
|
797
|
+
|| (y.goods_hits ?? -1) - (x.goods_hits ?? -1) || Number(y.in_market) - Number(x.in_market) || y.records - x.records || byName(x, y);
|
|
798
|
+
for (const g of ["a", "b", "c", "d"]) byGroup[g].lines.sort(herOrder);
|
|
799
|
+
// The rest runs by the client's goods words, then by live records in the ordered classes (ruled 2026-10-12):
|
|
800
|
+
// listed whole under a thousand records, by its first page at a thousand or more, like any group.
|
|
801
|
+
byGroup.rest.lines.sort((x, y) => (y.goods_hits ?? -1) - (x.goods_hits ?? -1) || y.live_in_scope - x.live_in_scope || byName(x, y));
|
|
802
|
+
// The lines the goods test could not place run floors first, then by live records in the ordered classes,
|
|
803
|
+
// then records: a floor here takes its own fate, so the floors lead the first page.
|
|
804
|
+
noGoods.sort((x, y) => Number(y.floors > 0) - Number(x.floors > 0) || y.live_in_scope - x.live_in_scope || y.records - x.records || byName(x, y));
|
|
805
|
+
return { dominant_term: dominantTerm || null, goods_on_file: Boolean(goods), markets: marketsGiven, territories: [...ordered], territories_unread: unreadTerritories,
|
|
806
|
+
outside: { lines: outsideLines, records: outsideRecords }, by_group: byGroup, no_goods: noGoods };
|
|
807
|
+
}
|
|
808
|
+
|
|
575
809
|
/**
|
|
576
810
|
* Build the deterministic band shape + its readable markdown mirror.
|
|
577
811
|
*
|
|
@@ -587,11 +821,11 @@ const floorRow = (r, cls) => ({
|
|
|
587
821
|
* (charter P2d); the band itself stays per-registration
|
|
588
822
|
* @returns { shape, md } deterministic — same band + inputs ⇒ same bytes (no timestamps)
|
|
589
823
|
*/
|
|
590
|
-
export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null } = {}) {
|
|
824
|
+
export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null, goods = null, goodsTest = null, goodsWordCount = 0, dominantTerm = "", manifestDominant = "", keysOf = null, markets = [], territories = [] } = {}) {
|
|
591
825
|
const prepared = prepareTargets(Array.isArray(targets) && targets.every(isPrepared) ? targets.map((t) => t.text) : targets);
|
|
592
826
|
const records = Array.isArray(band?.enumerated) ? band.enumerated : [];
|
|
593
827
|
const crowds = Array.isArray(band?.crowds) ? band.crowds : [];
|
|
594
|
-
const scope = new Set((inScopeClasses ?? []).map(
|
|
828
|
+
const scope = new Set((inScopeClasses ?? []).map(niceClassKey).filter(Boolean));
|
|
595
829
|
const inScope = (r) => {
|
|
596
830
|
const cls = classesOf(r);
|
|
597
831
|
if (!scope.size) return true; // no instructed classes ⇒ everything is in scope
|
|
@@ -606,8 +840,10 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
|
|
|
606
840
|
const unclassifiable = [];
|
|
607
841
|
const scriptGaps = new Map(); // script(s) key → { count, sample[] }
|
|
608
842
|
|
|
843
|
+
const classified = new Map();
|
|
609
844
|
for (const r of records) {
|
|
610
845
|
const cls = classifyRecord(r?.mark_text, prepared);
|
|
846
|
+
classified.set(r, cls);
|
|
611
847
|
byTier[cls.tier]++;
|
|
612
848
|
for (const c of classesOf(r)) inc(byClass, c);
|
|
613
849
|
inc(byStatus, String(r?.status ?? r?.screen?.status ?? "unknown").trim().toLowerCase() || "unknown");
|
|
@@ -643,6 +879,23 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
|
|
|
643
879
|
const floorsOrdered = probeOrder(floors, "floors");
|
|
644
880
|
|
|
645
881
|
const ownerCap = Number.isFinite(caps.owners) ? caps.owners : 40;
|
|
882
|
+
const grouped = bandGroups(records, { classify: (r) => classified.get(r), inScope, goods, dominantTerm, keysOf, markets, territories });
|
|
883
|
+
const ownersIn = (ls) => new Set(ls.map((l) => l.owner_key)).size;
|
|
884
|
+
const groupOf = (g) => {
|
|
885
|
+
const ls = grouped.by_group[g].lines;
|
|
886
|
+
return { records: grouped.by_group[g].records, set_records: grouped.by_group[g].set_records, lines: ls.length, owners: ownersIn(ls), list: ls };
|
|
887
|
+
};
|
|
888
|
+
const rest = grouped.by_group.rest.lines;
|
|
889
|
+
const groups = {
|
|
890
|
+
dominant_term: grouped.dominant_term, manifest_dominant: manifestDominant || null, goods_on_file: grouped.goods_on_file,
|
|
891
|
+
markets: grouped.markets, territories: grouped.territories, territories_unread: grouped.territories_unread, outside: grouped.outside,
|
|
892
|
+
goods_words_total: goodsWordCount || 0, goods_test: goodsTest ?? { source: goods ? "goods_words" : "none", words: [] },
|
|
893
|
+
crowded_at: CROWDED_GROUP_RECORDS, first_page: ownerCap,
|
|
894
|
+
a: groupOf("a"), b: groupOf("b"), c: groupOf("c"), d: groupOf("d"),
|
|
895
|
+
rest: { records: grouped.by_group.rest.set_records, lines: rest.length, owners: ownersIn(rest), list: rest },
|
|
896
|
+
no_goods: { records: grouped.no_goods.reduce((n, l) => n + l.records, 0), lines: grouped.no_goods.length,
|
|
897
|
+
owners: ownersIn(grouped.no_goods), list: grouped.no_goods },
|
|
898
|
+
};
|
|
646
899
|
const concentrations = [...owners.entries()]
|
|
647
900
|
.map(([owner_name, v]) => ({ owner_name, records: v.records, live: v.live }))
|
|
648
901
|
.sort((a, b) => b.records - a.records || a.owner_name.localeCompare(b.owner_name))
|
|
@@ -736,11 +989,12 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
|
|
|
736
989
|
|
|
737
990
|
const shape = {
|
|
738
991
|
schema_version: BAND_SHAPE_SCHEMA_VERSION,
|
|
739
|
-
role: "deterministic shape of the merged register band — judgment reads
|
|
992
|
+
role: "deterministic shape of the merged register band — judgment reads each group by the size sentence at its head and looks up records through the band tools (every lookup is logged); machine checks keep reading the complete band; tiers are mechanical string classes, never a relevance or materiality judgment",
|
|
740
993
|
targets: prepared.map((t) => t.text),
|
|
741
994
|
in_scope_classes: [...scope].sort(),
|
|
742
995
|
totals: { records: records.length, crowds: crowds.length, by_tier: byTier },
|
|
743
996
|
floors: { in_class_identical_or_near: floorsOrdered },
|
|
997
|
+
groups,
|
|
744
998
|
by_class: byClass,
|
|
745
999
|
by_status: byStatus,
|
|
746
1000
|
by_registry: byRegistry,
|
|
@@ -765,7 +1019,9 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
|
|
|
765
1019
|
} } : {}),
|
|
766
1020
|
};
|
|
767
1021
|
|
|
768
|
-
|
|
1022
|
+
const { md, lines } = bandShapeMdLines(shape, { positions });
|
|
1023
|
+
shape.md_lines = lines;
|
|
1024
|
+
return { shape, md };
|
|
769
1025
|
}
|
|
770
1026
|
|
|
771
1027
|
const fmtN = (n) => Number(n ?? 0).toLocaleString("en-US");
|
|
@@ -779,7 +1035,7 @@ function topTable(md, title, cols, entries, cap = 30) {
|
|
|
779
1035
|
if (rows.length > cap) md.push(`| … ${fmtN(rows.length - cap)} more (full detail in band-shape.json) | |`);
|
|
780
1036
|
}
|
|
781
1037
|
|
|
782
|
-
/** The readable mirror
|
|
1038
|
+
/** The readable mirror, served in parts when large; each floor is marked on its line, and the data keeps them all. PURE. */
|
|
783
1039
|
/**
|
|
784
1040
|
* §8 — mark → the sharpest floor tier that mark holds, for the write-up-form predicate.
|
|
785
1041
|
*
|
|
@@ -811,8 +1067,122 @@ export function floorTierByMark(shape) {
|
|
|
811
1067
|
/** The same normalisation the floors were built with, so a caller cannot key the map differently. */
|
|
812
1068
|
export const floorMarkKey = (s) => normMark(s);
|
|
813
1069
|
|
|
1070
|
+
// One line per mark of an owner, about 150 characters: the goods words it matched, never the goods text, and
|
|
1071
|
+
// whether it holds a floor. Each list carries the method's size sentence for its own length: read them all;
|
|
1072
|
+
// read the near ones first and list the rest; or, at a thousand records or more, the count and the first page.
|
|
1073
|
+
const GROUP_HEADINGS = {
|
|
1074
|
+
a: "(a) Identical or near-identical to the mark or one of its forms, live in the ordered classes, for the client's goods",
|
|
1075
|
+
b: "(b) Identical or near-identical to the mark or one of its forms, in the ordered classes",
|
|
1076
|
+
c: "(c) The same family as the mark, or carrying the searched word, for the client's goods",
|
|
1077
|
+
d: "(d) Every other live mark in the ordered classes, for the client's goods",
|
|
1078
|
+
};
|
|
1079
|
+
export const READ_ALL_LINES = 50;
|
|
1080
|
+
/** How many lines of the band hold a floor, wherever the shape put them. PURE. */
|
|
1081
|
+
const floorLineCount = (groups) => ["a", "b", "c", "d", "no_goods"].reduce((n, g) => n + (groups[g]?.list ?? []).filter((l) => l.floors > 0).length, 0);
|
|
1082
|
+
/** The lookup that pages a group, as the shape and the form's refusal both name it, so the two cannot drift. */
|
|
1083
|
+
export const groupPageCall = (g) => `band_lookup {"group":"${g}"}`;
|
|
1084
|
+
|
|
1085
|
+
// PLACEMENT'S ORDERS IN THE SHAPE. Three sentences under the groups tell the reader to give every line a fate,
|
|
1086
|
+
// and only placement owes the groups that. The band server serves the shape without them to every other
|
|
1087
|
+
// stage (Krzys's ruling, 2026-10-14), so they are declared once here and removed there by these same strings.
|
|
1088
|
+
export const SORT_THE_NEAR_ONES = "A few hundred? Sort by the client's goods and markets, read the near ones first, list the rest.";
|
|
1089
|
+
export const ONE_SET_ASIDE_ROW = "Write down what you set aside and why: one row, with this count.";
|
|
1090
|
+
export const A_FATE_BY_NAME = "Each line takes its own fate by name; no group row covers these lines.";
|
|
1091
|
+
export const PLACEMENT_GROUP_ORDERS = Object.freeze([SORT_THE_NEAR_ONES, ONE_SET_ASIDE_ROW, A_FATE_BY_NAME]);
|
|
1092
|
+
|
|
1093
|
+
function renderGroupsMd(md, groups, rows = []) {
|
|
1094
|
+
md.push("", "## The band in the lawyer's order — for the client's goods first", "");
|
|
1095
|
+
if (groups.dominant_term && groups.manifest_dominant && normKey(groups.dominant_term) !== normKey(groups.manifest_dominant))
|
|
1096
|
+
md.push(`Sorted on «${cell(groups.dominant_term)}», the word the register's goods-narrowed search asked for (the variants stage named «${cell(groups.manifest_dominant)}»).`, "");
|
|
1097
|
+
// The words the goods test read, and where they came from: the client's product words, or, where the
|
|
1098
|
+
// variants stage named none, the words the register search was narrowed by. A fallback is said, never silent.
|
|
1099
|
+
const tested = groups.goods_test ?? { source: "none", words: [] };
|
|
1100
|
+
const named = tested.words?.length ? `: ${tested.words.map((w) => `«${cell(w)}»`).join(", ")}` : "";
|
|
1101
|
+
md.push(tested.source === "product_words"
|
|
1102
|
+
? `The goods test reads each record's goods for the client's product words${named}.`
|
|
1103
|
+
: tested.source === "goods_words"
|
|
1104
|
+
? `No product words were given, so the goods test reads each record's goods for the words the register search was narrowed by${named}. They include words other filings use for the same goods, so "for the client's goods" below is wider than the client's own product.`
|
|
1105
|
+
: "No goods words were given, so no record was tested on its goods.", "");
|
|
1106
|
+
if (groups.territories_unread?.length)
|
|
1107
|
+
md.push(`The order names ${groups.territories_unread.map((t) => `«${cell(t)}»`).join(", ")}, which the engine cannot read as a territory, so the list holds every register.`, "");
|
|
1108
|
+
else if (groups.territories?.length) md.push(`The list holds the order's territories: ${groups.territories.join(", ")}.`, "");
|
|
1109
|
+
if (groups.markets?.length) md.push(`The client's markets: ${groups.markets.join(", ")}.`, "");
|
|
1110
|
+
const total = groups.goods_words_total || 0;
|
|
1111
|
+
const word = groups.dominant_term || groups.manifest_dominant;
|
|
1112
|
+
const carrying = word ? `carrying «${cell(word)}»` : "carrying the searched word";
|
|
1113
|
+
const heading = (g) => (g === "c" ? `(c) The same family as the mark, or ${carrying}, for the client's goods` : GROUP_HEADINGS[g]);
|
|
1114
|
+
const unread = (l) => (tested.source === "none" ? "—" : l.goods_on_file ? "on file, not readable" : "no goods on file");
|
|
1115
|
+
const words = (l) => (l.goods_words == null ? unread(l) : `${fmtN(l.goods_words.length)} of ${fmtN(total)}${l.goods_words.length ? `: ${l.goods_words.join(", ")}` : ""}`);
|
|
1116
|
+
const count = (grp) => `${fmtN(grp.records)} records in ${fmtN(grp.lines)} lines from ${fmtN(grp.owners)} owners`;
|
|
1117
|
+
const where = (l) => `${l.territories.join(",")}${l.outside ? " (outside the order)" : ""}`;
|
|
1118
|
+
const row = (l) => `| ${cell(l.mark)} | ${cell(l.owner_name)} | ${cell(l.classes.join(","))} | ${fmtN(l.live)} of ${fmtN(l.records)} | ${cell(where(l))} | ${cell(words(l))} | ${l.floors > 0 ? "yes" : "no"} | ${cell(l.record_id)} |`;
|
|
1119
|
+
const head = ["| mark | owner | classes | live | territories | goods words | floor | record |", "|---|---|---|---|---|---|---|---|"];
|
|
1120
|
+
// A crowded group's own words for what it holds, in the crowded-field method's sentence (ruled 2026-10-12).
|
|
1121
|
+
const crowd = { b: "identical marks in the ordered classes for other goods than the client's",
|
|
1122
|
+
c: "marks of the same family as the mark, or carrying the searched word, for the client's goods", d: "other marks in the ordered classes for the client's goods" };
|
|
1123
|
+
const firstPage = `The first ${fmtN(groups.first_page)} lines are below`;
|
|
1124
|
+
for (const g of ["a", "b", "c", "d"]) {
|
|
1125
|
+
const grp = groups[g];
|
|
1126
|
+
md.push(`### ${heading(g)}`, "");
|
|
1127
|
+
if (!grp.lines) { md.push("(none)", ""); continue; }
|
|
1128
|
+
const crowded = grp.records >= groups.crowded_at;
|
|
1129
|
+
const readA = "These are the identical and near-identical marks live in the ordered classes for the client's goods: read every line.";
|
|
1130
|
+
md.push(g === "a"
|
|
1131
|
+
? (crowded
|
|
1132
|
+
? `${count(grp)}. ${readA} ${firstPage}; page every other line with ${groupPageCall(g)}. Every floor here is placed or set aside by name; this group's row covers only its other lines.`
|
|
1133
|
+
: grp.lines <= READ_ALL_LINES ? `${count(grp)}. Fifty? Read them all.` : `${count(grp)}. ${readA}`)
|
|
1134
|
+
: crowded
|
|
1135
|
+
? `${count(grp)}. A thousand or more? The field is crowded. These are ${crowd[g]}. Read the first page, carry forward any line a lawyer would raise, and set the rest aside in this group's one row with the count: the client is told the field is crowded. ${firstPage}; page with ${groupPageCall(g)} only where a line needs reading.`
|
|
1136
|
+
: grp.lines <= READ_ALL_LINES
|
|
1137
|
+
? `${count(grp)}. Fifty? Read them all.`
|
|
1138
|
+
: `${count(grp)}. ${SORT_THE_NEAR_ONES}`, "");
|
|
1139
|
+
md.push(...head);
|
|
1140
|
+
for (const l of crowded ? grp.list.slice(0, groups.first_page) : grp.list) { rows.push([md.length, l.key]); md.push(row(l)); }
|
|
1141
|
+
md.push("");
|
|
1142
|
+
}
|
|
1143
|
+
const outside = groups.outside?.lines ? `, ${fmtN(groups.outside.lines)} lines of them held only outside the ordered territories` : "";
|
|
1144
|
+
// The rest is read by the size rule too: listed under a thousand records, by its first page at a thousand or
|
|
1145
|
+
// more, where its one row stands on that page read and the count.
|
|
1146
|
+
const rest = groups.rest;
|
|
1147
|
+
const restCrowded = rest.records >= groups.crowded_at;
|
|
1148
|
+
md.push(`**The rest: ${count(rest)}${outside}** — every record in none of (a) to (d) above. ${!rest.lines ? ONE_SET_ASIDE_ROW
|
|
1149
|
+
: restCrowded
|
|
1150
|
+
? `A thousand or more? The field is crowded. Read the first page, carry forward any line a lawyer would raise, and set the rest aside in this group's one row with the count: the client is told the field is crowded. ${firstPage}; page with ${groupPageCall("rest")} only where a line needs reading.`
|
|
1151
|
+
: `${rest.lines <= READ_ALL_LINES ? "Fifty? Read them all." : SORT_THE_NEAR_ONES} ${ONE_SET_ASIDE_ROW}`}`);
|
|
1152
|
+
if (rest.lines) {
|
|
1153
|
+
md.push("", ...head);
|
|
1154
|
+
for (const l of restCrowded ? rest.list.slice(0, groups.first_page) : rest.list) { rows.push([md.length, l.key]); md.push(row(l)); }
|
|
1155
|
+
}
|
|
1156
|
+
if (groups.no_goods.lines) {
|
|
1157
|
+
// The lines the goods test could not place, read by the same size rule (ruled 2026-10-12): under a thousand
|
|
1158
|
+
// records, a fate per line by name, opened where goods are on file; at a thousand or more, the first page,
|
|
1159
|
+
// and one row on the rest whose goods are unreadable in a crowded field. A floor is never set aside on
|
|
1160
|
+
// its goods, so a floor here is placed or set aside on its status or territory.
|
|
1161
|
+
const ng = groups.no_goods;
|
|
1162
|
+
const crowded = ng.records >= groups.crowded_at;
|
|
1163
|
+
const lead = tested.source === "none"
|
|
1164
|
+
? `**Not tested on goods: ${count(ng)}.** No goods words were given, so the goods test could not place them.`
|
|
1165
|
+
: `**Goods the test could not read: ${count(ng)}.**`;
|
|
1166
|
+
md.push("", crowded
|
|
1167
|
+
? `${lead} A thousand or more? ${firstPage}, the floors first, then the live ones in the ordered classes. Carry forward any line a lawyer would raise, and set the rest aside in this list's one row with the count, on the ground that their goods are unreadable and the field is crowded. A floor here is placed or set aside by name on its status or its territory; this row never covers it. Where more floors are counted than this page shows, page the rest with ${groupPageCall("no_goods")}; they come first.`
|
|
1168
|
+
: `${lead} ${ng.lines <= READ_ALL_LINES ? "Fifty? Read them all." : "A few hundred? Read the live ones in the ordered classes first."} ${A_FATE_BY_NAME}${tested.source === "none" ? "" : ' A line marked "on file, not readable" has goods on file: open its record with band_record before its fate. A line marked "no goods on file" has nothing to open.'} A floor here is placed, or set aside by name on its status or its territory; any other line may be set aside on the ground that its goods are unknown.`, "");
|
|
1169
|
+
md.push(...head);
|
|
1170
|
+
for (const l of crowded ? ng.list.slice(0, groups.first_page) : ng.list) { rows.push([md.length, l.key]); md.push(row(l)); }
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
814
1174
|
export function renderBandShapeMd(shape, { positions = null } = {}) {
|
|
815
|
-
|
|
1175
|
+
return bandShapeMdLines(shape, { positions }).md;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* The readable shape, and the md line each listed line is printed on: `{ md, lines: {lineKey: lineNumber} }`,
|
|
1180
|
+
* 0-based over `md.split("\n")`. The band server reads `lines` from the shape's data to log which lines a
|
|
1181
|
+
* part it serves holds, so a group's row is checked against what the step read, not against what was
|
|
1182
|
+
* printed. PURE.
|
|
1183
|
+
*/
|
|
1184
|
+
export function bandShapeMdLines(shape, { positions = null } = {}) {
|
|
1185
|
+
const md = [], rows = [];
|
|
816
1186
|
md.push("# Band shape — the register band, mechanically mapped");
|
|
817
1187
|
md.push("");
|
|
818
1188
|
md.push("Deterministic projection of the complete merged register band (code-derived after every band");
|
|
@@ -829,17 +1199,17 @@ export function renderBandShapeMd(shape, { positions = null } = {}) {
|
|
|
829
1199
|
md.push("|---|---|");
|
|
830
1200
|
for (const t of SHAPE_TIERS) md.push(`| ${t} | ${fmtN(shape.totals.by_tier[t])} |`);
|
|
831
1201
|
|
|
1202
|
+
// THE FLOORS ARE MARKED ON THEIR LINES, NOT LISTED A SECOND TIME (ruled 2026-10-12). The band in the ruled order
|
|
1203
|
+
// carries every floor as a line, and a per-record list of the same records was most of a crowded shape the
|
|
1204
|
+
// step had to read whole before it could act. The shape's data keeps every floor for the floor duty, and
|
|
1205
|
+
// `band_lookup` returns any floor record on request.
|
|
832
1206
|
const floors = shape.floors.in_class_identical_or_near;
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
for (const f of floors) {
|
|
840
|
-
md.push(`| ${cell(f.mark_text)} | ${f.tier} | ${cell(f.matched_target)} | ${cell(f.classes.join(","))} | ${cell(f.status)} | ${cell(f.owner_name)} | ${cell(f.registry)} | ${cell(f.record_id)} |`);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
1207
|
+
const floorLines = shape.groups ? floorLineCount(shape.groups) : null;
|
|
1208
|
+
md.push("", floors.length
|
|
1209
|
+
? `**Floors: ${fmtN(floors.length)} records${floorLines != null ? ` in ${fmtN(floorLines)} lines` : ""}** — every live in-class identical or near-identical record, each marked on its line in the band below.`
|
|
1210
|
+
: "**Floors:** none — no live in-class record classified identical or near-identical to any target.");
|
|
1211
|
+
|
|
1212
|
+
if (shape.groups) renderGroupsMd(md, shape.groups, rows);
|
|
843
1213
|
|
|
844
1214
|
topTable(md, "By Nice class", ["class", "records"], shape.by_class);
|
|
845
1215
|
topTable(md, "By status", ["status", "records"], shape.by_status);
|
|
@@ -929,5 +1299,12 @@ export function renderBandShapeMd(shape, { positions = null } = {}) {
|
|
|
929
1299
|
if ((b.records ?? b.sample ?? b.zones ?? []).length > 20) md.push(` - … and ${fmtN((b.records ?? b.sample ?? b.zones).length - 20)} more (band-shape.json carries all)`);
|
|
930
1300
|
}
|
|
931
1301
|
md.push("");
|
|
932
|
-
|
|
1302
|
+
// A pushed element may hold line breaks of its own, so each row's line number counts the breaks before it.
|
|
1303
|
+
const lines = {};
|
|
1304
|
+
let at = 0, line = 0;
|
|
1305
|
+
for (const [index, key] of rows) {
|
|
1306
|
+
for (; at < index; at++) line += 1 + (md[at].match(/\n/g)?.length ?? 0);
|
|
1307
|
+
if (key != null) lines[key] = line;
|
|
1308
|
+
}
|
|
1309
|
+
return { md: md.join("\n"), lines };
|
|
933
1310
|
}
|