clearotron 0.5.0-beta.0 → 0.5.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +9 -0
- package/driver/ask-ledger.mjs +4 -1
- package/driver/band-shape.mjs +5 -1
- package/driver/contract-vocabulary.mjs +3 -3
- package/driver/coverage-form-io.mjs +2 -1
- package/driver/coverage-form.mjs +14 -2
- package/driver/effective-scope.mjs +12 -7
- package/driver/engine/mcp/held-answers.mjs +128 -0
- package/driver/engine/mcp/supplemental.mjs +74 -12
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +53 -13
- package/driver/register-plan.mjs +132 -6
- package/driver/scope-facts.mjs +6 -2
- package/driver/skills/clearance-register/SKILL.md +24 -16
- package/driver/skills/clearance-register/digest.md +8 -3
- package/driver/skills/clearance-register/providers/clarivate.md +4 -4
- package/driver/skills/clearance-register/providers/corsearch.md +1 -1
- package/driver/skills/clearance-register/providers/euipo.md +4 -4
- package/driver/skills/clearance-register/providers/free-tier.md +4 -4
- package/driver/skills/clearance-register/providers/uspto-local.md +3 -3
- package/driver/skills/clearance-register/register-recipes.md +6 -6
- package/driver/skills/clearance-register/unit.md +26 -19
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +49 -7
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/enumerate.mjs +12 -6
- package/providers/_shared/execute-plan.mjs +25 -1
- package/providers/_shared/follow-up-searches.mjs +139 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e-first-time.mjs +1 -1
- package/scripts/release-await-cut.mjs +4 -2
package/build-info.json
CHANGED
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# clearotron-driver
|
|
2
2
|
|
|
3
|
+
## 0.5.0-beta.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fixed: A search with more similar marks than can be read now narrows step by step, and stops once the lists it has read answer it. Before, it could keep searching far past what the advice needed.
|
|
8
|
+
- Fixed: A crowded search that the lists already read have answered no longer appears among the report's coverage limits. Its count is still recorded.
|
|
9
|
+
- For operators: A clearance now stops asking further follow-up searches after 120, and delivers its report with what it has read.
|
|
10
|
+
- For operators: On the Signa register, follow-up searches now send spellings as lists, so a clearance makes fewer requests.
|
|
11
|
+
|
|
3
12
|
## 0.5.0-beta.0
|
|
4
13
|
|
|
5
14
|
### Minor Changes
|
package/driver/ask-ledger.mjs
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
// DRIVER's instructed values appear in the model's frame, where this checks the MODEL's quote
|
|
55
55
|
// appears in a driver-held file. Same expression, different question; it stays separate.
|
|
56
56
|
import { squash } from "./doubt-ledger.mjs";
|
|
57
|
+
import { OUTSTANDING_REASON } from "../providers/_shared/follow-up-searches.mjs";
|
|
57
58
|
//
|
|
58
59
|
// Ask record shape (frozen — _driver/asks.json and the audit's Ask Ledger both render it):
|
|
59
60
|
// {
|
|
@@ -469,7 +470,9 @@ export function deriveAsks({
|
|
|
469
470
|
// must show it working. So EVERY rejection attempts the join, and one that did not mint later
|
|
470
471
|
// ends OPEN — a real unanswered question the closure stage settles with a citation or that ships
|
|
471
472
|
// visibly. "judged-immaterial" is reserved for a materiality judgment somebody actually made.
|
|
472
|
-
|
|
473
|
+
// A follow-up the run's safety net did not send is never superseded: the identity join ignores the regions, so a
|
|
474
|
+
// question minted for one market would otherwise bury the same question refused for another.
|
|
475
|
+
const supersededBy = issue === OUTSTANDING_REASON ? null : (mintedByIdentity.get(proposalIdentity(r?.proposal)) ?? null);
|
|
473
476
|
push({
|
|
474
477
|
ask_id: `ask:supplemental-rejected:${slug(axis)}:${i + 1}`,
|
|
475
478
|
born: { place: "supplemental-proposal", artifact: `register-units/${axis}-supplemental-plan.json`, ref: `rejected[${i}]`, ts: r?.ts ?? null },
|
package/driver/band-shape.mjs
CHANGED
|
@@ -830,7 +830,7 @@ export function bandGroups(records, { classify, inScope, goods = null, dominantT
|
|
|
830
830
|
* (charter P2d); the band itself stays per-registration
|
|
831
831
|
* @returns { shape, md } deterministic — same band + inputs ⇒ same bytes (no timestamps)
|
|
832
832
|
*/
|
|
833
|
-
export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null, goods = null, goodsTest = null, goodsWordCount = 0, dominantTerm = "", manifestDominant = "", keysOf = null, markets = [], territories = [], evidenceOf = null } = {}) {
|
|
833
|
+
export function buildBandShape(band, { crowdNarrowing = [], targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null, goods = null, goodsTest = null, goodsWordCount = 0, dominantTerm = "", manifestDominant = "", keysOf = null, markets = [], territories = [], evidenceOf = null } = {}) {
|
|
834
834
|
const prepared = prepareTargets(Array.isArray(targets) && targets.every(isPrepared) ? targets.map((t) => t.text) : targets);
|
|
835
835
|
const records = Array.isArray(band?.enumerated) ? band.enumerated : [];
|
|
836
836
|
const crowds = Array.isArray(band?.crowds) ? band.crowds : [];
|
|
@@ -914,8 +914,11 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
|
|
|
914
914
|
// context slice joins a crowd when one of its terms appears in the crowd's query (case-folded).
|
|
915
915
|
const ccSlices = Array.isArray(crowdContext?.slices) ? crowdContext.slices : [];
|
|
916
916
|
const fold = (s) => foldDiacritics(String(s ?? "")).toLowerCase();
|
|
917
|
+
// what was retrieved under each crowd (register-plan.mjs, crowdNarrowing): data on the descriptor, by its qid
|
|
918
|
+
const endingByQid = new Map((Array.isArray(crowdNarrowing) ? crowdNarrowing : []).filter((r) => r?.qid && r?.narrowing).map((r) => [String(r.qid), r]));
|
|
917
919
|
const shapeCrowds = crowds.map((c) => {
|
|
918
920
|
const q = fold(c.query);
|
|
921
|
+
const end = c.qid ? endingByQid.get(String(c.qid)) : null;
|
|
919
922
|
const joined = ccSlices
|
|
920
923
|
.filter((s) => (Array.isArray(s.terms) ? s.terms : []).some((t) => fold(t) && q.includes(fold(t))))
|
|
921
924
|
.map((s) => ({ unit: s.unit ?? null, axis: s.axis ?? null, terms: s.terms ?? [],
|
|
@@ -929,6 +932,7 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
|
|
|
929
932
|
// executor and judgment, and dropping them here would undo the first carry silently.
|
|
930
933
|
...(c.error === true ? { error: true } : {}),
|
|
931
934
|
...(c.deferred === true ? { deferred: true } : {}),
|
|
935
|
+
...(end ? { narrowing: end.narrowing, ...(end.lists?.length ? { lists: end.lists } : {}), ...(end.not_covered?.length ? { not_covered: end.not_covered } : {}) } : {}),
|
|
932
936
|
crowd_context_slice: joined.length ? joined : null,
|
|
933
937
|
};
|
|
934
938
|
});
|
|
@@ -530,9 +530,9 @@ export const INNER_CODES = Object.freeze([
|
|
|
530
530
|
// ONE CODE, TWO TOKENS, and the split is by `cause` rather than by code: verify.mjs peels the
|
|
531
531
|
// `axis_invalid` cause into its own family before counting the rest. A ruling naming only
|
|
532
532
|
// `coverage_no_status` would be true of most `no_status` records and false of the ones that matter most.
|
|
533
|
-
{ code: "no_status", mints: ["driver/coverage-form.mjs:
|
|
533
|
+
{ code: "no_status", mints: ["driver/coverage-form.mjs:907"], rollsUpTo: ["coverage_no_status", "coverage_form_axis_invalid"],
|
|
534
534
|
why: "Two composites, split on the record's `cause`: coverageFormFail() in verify.mjs for cause `axis_invalid`, and the same function for the rest. NOT the `@returns` annotation of findCoverageFormViolations() in coverage-form.mjs — that is the annotation, not the mint." },
|
|
535
|
-
{ code: "engine_vocabulary", mints: ["driver/coverage-form.mjs:
|
|
535
|
+
{ code: "engine_vocabulary", mints: ["driver/coverage-form.mjs:897"], rollsUpTo: ["coverage_form_engine_vocabulary"],
|
|
536
536
|
why: "#669 — the seat wrote an engine token into the `reason` sentence that reaches the reader's page. Checked on settled rows too, because a row the seat considers finished is exactly the one whose sentence gets printed. Namespaced by coverageFormFail() in verify.mjs; the bare code names a row, never a stage." },
|
|
537
537
|
|
|
538
538
|
// ── ONE CODE, TWO MODULES ────────────────────────────────────────────────────────────────────────
|
|
@@ -544,7 +544,7 @@ export const INNER_CODES = Object.freeze([
|
|
|
544
544
|
// failure tokens" already states the rule this row records: `coverage_form_damaged`, never a bare
|
|
545
545
|
// `form_damaged`.
|
|
546
546
|
{ code: "form_damaged",
|
|
547
|
-
mints: ["driver/connotation-search.mjs:2002", "driver/connotation-search.mjs:2078", "driver/coverage-form.mjs:
|
|
547
|
+
mints: ["driver/connotation-search.mjs:2002", "driver/connotation-search.mjs:2078", "driver/coverage-form.mjs:889"],
|
|
548
548
|
rollsUpTo: ["connotation_form_damaged", "coverage_form_damaged"],
|
|
549
549
|
why: "Minted in two lanes and namespaced per lane: connotationDispositionFail() in verify.mjs for the meaning sweep, coverageFormFail() in verify.mjs for the register digest. The namespacing is what keeps them apart — see verify.mjs's block headed \"the register coverage-form failure tokens\"." },
|
|
550
550
|
]);
|
|
@@ -149,7 +149,8 @@ export function coverageFormInput(runDir) {
|
|
|
149
149
|
const orderedTerritories = Array.isArray(plan.ordered_jurisdictions) ? plan.ordered_jurisdictions : [];
|
|
150
150
|
return { input: { skeleton: exec.skeleton, plan, bandBlocksByAxis, deferredReasons, activeAxes,
|
|
151
151
|
bandsUnreadable, orderedTerritories, capabilities, unknownAxisUnits,
|
|
152
|
-
awaiting: Array.isArray(exec.awaiting) ? exec.awaiting : [], withheld: readWithheldFamilies(runDir)
|
|
152
|
+
awaiting: Array.isArray(exec.awaiting) ? exec.awaiting : [], withheld: readWithheldFamilies(runDir),
|
|
153
|
+
mainMarkets: Array.isArray(exec.main_markets) ? exec.main_markets : [] }, absent: null };
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
/**
|
package/driver/coverage-form.mjs
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { REGISTER_AXES, COVERAGE_STATUSES, normalizeAxis, CROWD_RULING_TOKEN,
|
|
32
32
|
CROWD_RULING_UNIT_GRAMMAR } from "./coverage-ledger.mjs";
|
|
33
33
|
import { shortId } from "./connotation-search.mjs";
|
|
34
|
-
import { openBlocksByAxis } from "./register-plan.mjs";
|
|
34
|
+
import { openBlocksByAxis, crowdNarrowing, blocksByQidOf } from "./register-plan.mjs";
|
|
35
35
|
import { territoryLayerReport, unsearchedLayerReason } from "./binding-layers.mjs"; import { goodsTermsList } from "../providers/_shared/term-shape.mjs";
|
|
36
36
|
|
|
37
37
|
const STATUS_SET = new Set(COVERAGE_STATUSES);
|
|
@@ -344,8 +344,17 @@ export function blockOpenBecause(b, narrowings = []) {
|
|
|
344
344
|
*/
|
|
345
345
|
export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null,
|
|
346
346
|
bandBlocksByAxis = {}, deferredReasons = {}, bandsUnreadable = [],
|
|
347
|
-
orderedTerritories = [], capabilities = null, awaiting = [], withheld = {} } = {}) {
|
|
347
|
+
orderedTerritories = [], capabilities = null, awaiting = [], withheld = {}, mainMarkets = [] } = {}) {
|
|
348
348
|
const skel = Array.isArray(skeleton) ? skeleton.filter((s) => s && typeof s === "object") : [];
|
|
349
|
+
// WHAT WAS RETRIEVED UNDER EACH CROWD (register-plan.mjs, crowdNarrowing), worked out from the same blocks these rows
|
|
350
|
+
// are built from and carried on its row as data: a narrowed list retrieved in full (and which), none, or for the
|
|
351
|
+
// identical mark whether retrieved lists cover every class in every main market (and the cells they do not). The
|
|
352
|
+
// row stays open either way: reading the lists and deciding the crowd are the reading step's.
|
|
353
|
+
const narrowingByQid = new Map(crowdNarrowing(plan, blocksByQidOf(bandBlocksByAxis), { mainMarkets }).rows.map((r) => [String(r.qid), r]));
|
|
354
|
+
const endingOf = (qid) => {
|
|
355
|
+
const r = narrowingByQid.get(String(qid));
|
|
356
|
+
return r ? { narrowing: r.narrowing, ...(r.lists?.length ? { lists: r.lists } : {}), ...(r.not_covered?.length ? { not_covered: r.not_covered } : {}) } : {};
|
|
357
|
+
};
|
|
349
358
|
const entriesByQid = new Map((plan?.entries ?? []).map((e) => [e.qid, e]));
|
|
350
359
|
const open = openBlocksByAxis(skel, bandBlocksByAxis, plan);
|
|
351
360
|
// Axis order is the run's, not a set's iteration order: the plan-execution skeleton first (the order
|
|
@@ -393,6 +402,7 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
|
|
|
393
402
|
...(Number.isInteger(b.total_hits) ? { total_hits: b.total_hits } : {}),
|
|
394
403
|
...(b.unaccounted?.length ? { unaccounted_terms: b.unaccounted } : {}),
|
|
395
404
|
...(b.unaccounted_classes?.length ? { unaccounted_classes: b.unaccounted_classes } : {}),
|
|
405
|
+
...endingOf(b.qid),
|
|
396
406
|
// The block's OWN row is the naming the deleted join required. A clean claim HERE is the
|
|
397
407
|
// silent swallow; a clean claim on a sibling slice never discharged this block and does not now.
|
|
398
408
|
open: true,
|
|
@@ -1034,6 +1044,8 @@ function briefRow(r) {
|
|
|
1034
1044
|
r.unaccounted_classes?.length ? `classes unaccounted: ${r.unaccounted_classes.join(", ")}` : "",
|
|
1035
1045
|
r.unaccounted_terms?.length ? `terms unaccounted: ${r.unaccounted_terms.join(", ")}` : "",
|
|
1036
1046
|
r.receipt_reason ? `receipt: ${String(r.receipt_reason).slice(0, 160)}` : "",
|
|
1047
|
+
// what was retrieved under the crowd (register-plan.mjs, crowdNarrowing), as data beside the counts it rests on
|
|
1048
|
+
r.narrowing ? `narrowing: ${r.narrowing}${r.lists?.length ? ` (${r.lists.slice(0, 6).join(", ")})` : ""}${r.not_covered?.length ? `; not covered: ${r.not_covered.slice(0, 12).join(", ")}` : ""}` : "",
|
|
1037
1049
|
].filter(Boolean).join("; ");
|
|
1038
1050
|
const settled = String(r.status ?? "").trim() ? ` [settled: ${String(r.status).trim()}]` : "";
|
|
1039
1051
|
return ` ${r.row_id} (${r.kind}) ${r.unit}${facts ? ` — ${facts}` : ""}${r.open === true ? " — OPEN: never confirmed-clean" : ""}${settled}`;
|
|
@@ -192,19 +192,24 @@ export function orderTerritories(job, profile) {
|
|
|
192
192
|
/**
|
|
193
193
|
* THE NARROWING ORDER, as the register reading step is told it (ruled 2026-09-26): goods and class
|
|
194
194
|
* first, then the markets in turn, with the markets the driver knows filled in. One line. PURE.
|
|
195
|
+
*
|
|
196
|
+
* ONE MARKET LIST, the one that applies: the order's countries; else the customer's priority markets; else the
|
|
197
|
+
* markets the customer's field and brand point to, with the major markets as their fallback. The line used to
|
|
198
|
+
* print all three as a chain of "else", and the reading step was left to pick among them. Each step keeps the
|
|
199
|
+
* limits before it, and a crowd still a crowd after the markets is recorded with its count: the narrowing ends.
|
|
195
200
|
*/
|
|
196
201
|
export function narrowingOrderLine(job, profile, inScopeClasses = null) {
|
|
197
202
|
const { scope, priorities, major } = narrowingMarkets(job, profile);
|
|
198
203
|
const markets = scope.length
|
|
199
204
|
? `one question per market of the order's scope (${scope.join(", ")}); nothing outside it was ordered`
|
|
200
|
-
:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
`the major markets (${major.join(", ")})`,
|
|
204
|
-
].filter(Boolean).join(", else ")}`;
|
|
205
|
+
: priorities.length
|
|
206
|
+
? `one question per market: the customer's priority markets (${priorities.join(", ")})`
|
|
207
|
+
: `one question per market: the markets the customer's field and brand point to (judge them from the matter frame), else the major markets (${major.join(", ")})`;
|
|
205
208
|
return `NARROWING A CROWD, in this order: first the goods and the classes, the client's goods words in `
|
|
206
|
-
+ `${inScopeClasses ? `the order's classes [${inScopeClasses}]` : "the order's classes"}, then one of those classes at a time
|
|
207
|
-
+ `
|
|
209
|
+
+ `${inScopeClasses ? `the order's classes [${inScopeClasses}]` : "the order's classes"}, then one of those classes at a time, `
|
|
210
|
+
+ `each step keeping the limits before it and stopping at the first list you can read; `
|
|
211
|
+
+ `still a crowd, then the markets, ${markets}. Still a crowd after the markets: record it with its count. `
|
|
212
|
+
+ `From the narrowed lists, read what a lawyer would raise. `
|
|
208
213
|
+ `What was counted and not read stays on its coverage row with its count: set aside, never clean.`;
|
|
209
214
|
}
|
|
210
215
|
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
// held-answers.mjs — ONE ANSWER PER QUESTION PER RUN, ACROSS EVERY WORKER AND PASS.
|
|
5
|
+
//
|
|
6
|
+
// The register workers run in parallel and do not see each other's questions, so the same question was put to the
|
|
7
|
+
// register again by another worker: on a test run of a crowded matter, 178 follow-up proposals held 167 distinct
|
|
8
|
+
// questions, and the 11 copies cost 49 calls. A question the run has already answered now gets the held answer, and
|
|
9
|
+
// one the run has already decided not to ask (a family withheld by judgment, or a follow-up the run's safety net
|
|
10
|
+
// refused) gets that decision. Nothing is asked again and no call is made.
|
|
11
|
+
//
|
|
12
|
+
// WHAT MAKES TWO QUESTIONS THE SAME: every field that changes what the register is asked — the predicate, the term
|
|
13
|
+
// or terms, their romanised forms, the classes, the markets, the goods words, the owner and the kind of answer —
|
|
14
|
+
// and none that only records where a question came from. A question with no markets is asked to the plan's, so it
|
|
15
|
+
// is the same as one naming them. Owner names are compared as written, never merged. A different scope is a
|
|
16
|
+
// different question, and an earlier answer that failed is no answer: both are asked again.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { driverDir } from "../../../shared/driver-dir.mjs";
|
|
21
|
+
import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
|
|
22
|
+
import { awaitsReadingTurn } from "../../../providers/_shared/plan-guards.mjs";
|
|
23
|
+
import { releasedFamilyQids } from "../../withheld-families.mjs";
|
|
24
|
+
|
|
25
|
+
const QUESTION_FIELDS = ["predicate", "term", "terms", "romanizedTerms", "term_literal", "nice_classes", "regions", "goods_text", "owner", "expected_kind"];
|
|
26
|
+
const sorted = (v) => [...v].map((x) => String(x)).sort();
|
|
27
|
+
|
|
28
|
+
/** The question an entry asks, as a key. `planRegions` fills an entry that names no markets. PURE. */
|
|
29
|
+
export function questionKey(e, planRegions = []) {
|
|
30
|
+
const o = {};
|
|
31
|
+
for (const k of QUESTION_FIELDS) {
|
|
32
|
+
let v = e?.[k];
|
|
33
|
+
if (k === "predicate") v = String(v ?? "default");
|
|
34
|
+
if (k === "expected_kind") v = String(v ?? "enumerate");
|
|
35
|
+
if (k === "regions" && !(Array.isArray(v) && v.length)) v = planRegions;
|
|
36
|
+
if (v === undefined || v === null || v === false || (Array.isArray(v) && !v.length) || v === "") continue;
|
|
37
|
+
o[k] = Array.isArray(v) ? (k === "terms" || k === "romanizedTerms" ? v.map(String) : sorted(v)) : v;
|
|
38
|
+
}
|
|
39
|
+
return JSON.stringify(o);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A band file is read once per change: the propose tool is called many times in a turn, and a primary sweep's band
|
|
43
|
+
// runs to tens of megabytes. Keyed on the path, its size and its modification time. Only what a held answer hands
|
|
44
|
+
// back is kept, the first records among it, so a worker's process does not hold every band whole.
|
|
45
|
+
const BAND_CACHE = new Map();
|
|
46
|
+
const HELD_RECORDS = 25;
|
|
47
|
+
function readBlocks(path) {
|
|
48
|
+
let st;
|
|
49
|
+
try { st = statSync(path); } catch { return []; }
|
|
50
|
+
const stamp = `${st.size}:${st.mtimeMs}`;
|
|
51
|
+
const hit = BAND_CACHE.get(path);
|
|
52
|
+
if (hit && hit.stamp === stamp) return hit.blocks;
|
|
53
|
+
let blocks = [];
|
|
54
|
+
try {
|
|
55
|
+
const j = JSON.parse(readFileSync(path, "utf8"));
|
|
56
|
+
blocks = (Array.isArray(j) ? j : []).filter((b) => b?.qid).map((b) => ({
|
|
57
|
+
qid: b.qid, state: b.state ?? null, error: b.error === true, total_hits: b.total_hits ?? null,
|
|
58
|
+
count: b.count ?? (Array.isArray(b.records) ? b.records.length : null),
|
|
59
|
+
records: Array.isArray(b.records) ? b.records.slice(0, HELD_RECORDS) : [],
|
|
60
|
+
}));
|
|
61
|
+
} catch { blocks = []; }
|
|
62
|
+
BAND_CACHE.set(path, { stamp, blocks });
|
|
63
|
+
return blocks;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Refuse the minted entries whose question the run has already answered on another row, the way the fold refuses a
|
|
68
|
+
* question the plan already holds: `duplicate-question`, with the row that answered it as the twin. Second framing's
|
|
69
|
+
* re-sweep calls this before its fold, because the fold sees only the plan's rows on the re-sweep's own axis, and a
|
|
70
|
+
* question another worker answered is not asked again. Only an answer settles: a withheld family has its own refusal,
|
|
71
|
+
* and a follow-up the safety net did not send would not be sent again. PURE.
|
|
72
|
+
* @returns {{ kept: object[], refused: {qid: string, term: string, kind: "duplicate-question", twin: string}[] }}
|
|
73
|
+
*/
|
|
74
|
+
export function refuseHeldQuestions(entries, held, planRegions = []) {
|
|
75
|
+
const kept = [], refused = [];
|
|
76
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
77
|
+
const h = held?.get?.(questionKey(e, planRegions));
|
|
78
|
+
if (e?.qid && h?.decision === "answered" && h.qid && String(h.qid) !== String(e.qid))
|
|
79
|
+
refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "duplicate-question", twin: String(h.qid) });
|
|
80
|
+
else kept.push(e);
|
|
81
|
+
}
|
|
82
|
+
return { kept, refused };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The run's held answers and decisions, by question key. IMPURE (reads the run's register files).
|
|
87
|
+
* @returns {Map<string, { qid: string, axis: string, decision: "answered"|"withheld"|"outstanding", block?: object }>}
|
|
88
|
+
*/
|
|
89
|
+
export function heldAnswers(runDir, { planRegions = [], withheld = {}, released = null } = {}) {
|
|
90
|
+
// A FAMILY WAITING FOR THE READING TURN IS ANSWERED ONLY ONCE IT IS RELEASED AND RUN, the plan join's own rule: until
|
|
91
|
+
// then it was not asked, whatever block its qid may carry, and a question repeating it is asked as the join expects.
|
|
92
|
+
let releasedQids = released;
|
|
93
|
+
if (!(releasedQids instanceof Set)) { try { releasedQids = releasedFamilyQids(runDir); } catch { releasedQids = new Set(); } }
|
|
94
|
+
const held = new Map();
|
|
95
|
+
const units = join(runDir, "register-units");
|
|
96
|
+
const blocksByQid = new Map();
|
|
97
|
+
const plans = [];
|
|
98
|
+
try { plans.push({ axis: null, plan: JSON.parse(readFileSync(driverDir(runDir, "register-plan.json"), "utf8")) }); } catch { /* no frozen plan */ }
|
|
99
|
+
let files = [];
|
|
100
|
+
try { files = readdirSync(units); } catch { files = []; }
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
if (f.endsWith("-band.json")) for (const b of readBlocks(join(units, f))) if (b?.qid) blocksByQid.set(String(b.qid), b);
|
|
103
|
+
if (f.endsWith("-supplemental-plan.json")) {
|
|
104
|
+
try { plans.push({ axis: f.slice(0, -"-supplemental-plan.json".length), plan: JSON.parse(readFileSync(join(units, f), "utf8")) }); } catch { /* torn: skipped */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const { plan } of plans) {
|
|
108
|
+
for (const e of Array.isArray(plan?.entries) ? plan.entries : []) {
|
|
109
|
+
if (!e?.qid) continue;
|
|
110
|
+
const key = questionKey(e, planRegions);
|
|
111
|
+
if (held.has(key)) continue;
|
|
112
|
+
if (awaitsReadingTurn(e.when) && !releasedQids.has(String(e.qid))) {
|
|
113
|
+
if (withheld?.[e.qid]) held.set(key, { qid: e.qid, axis: e.axis, decision: "withheld" });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const b = blocksByQid.get(String(e.qid));
|
|
117
|
+
if (b && b.error !== true) held.set(key, { qid: e.qid, axis: e.axis, decision: "answered", block: b });
|
|
118
|
+
else if (withheld?.[e.qid]) held.set(key, { qid: e.qid, axis: e.axis, decision: "withheld" });
|
|
119
|
+
}
|
|
120
|
+
// A follow-up the run's safety net did not send is a decision too: proposed again, it would not be sent again.
|
|
121
|
+
for (const r of Array.isArray(plan?.rejected) ? plan.rejected : []) {
|
|
122
|
+
if (r?.issue !== OUTSTANDING_REASON || !r?.proposal) continue;
|
|
123
|
+
const key = questionKey(r.proposal, planRegions);
|
|
124
|
+
if (!held.has(key)) held.set(key, { qid: null, axis: null, decision: "outstanding" });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return held;
|
|
128
|
+
}
|
|
@@ -32,10 +32,18 @@
|
|
|
32
32
|
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
|
|
33
33
|
import { dirname, join } from "node:path";
|
|
34
34
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
35
|
-
import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
|
|
35
|
+
import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, planExactOrWidth, planPhoneticOrWidth, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
|
|
36
36
|
import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
|
|
37
|
+
import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
|
|
38
|
+
import { heldAnswers, questionKey } from "./held-answers.mjs";
|
|
39
|
+
import { readWithheldFamilies } from "../../withheld-families.mjs";
|
|
37
40
|
import { isNonLatinTerm, romanizationRefusal, romanizationSpellings, nativeScriptIndexGap } from "../../../providers/_shared/script-form.mjs";
|
|
38
41
|
|
|
42
|
+
// THE ONE SENTENCE THE MODEL READS WHEN THE RUN'S SAFETY NET HOLDS A FOLLOW-UP BACK (follow-up-searches.mjs). It names
|
|
43
|
+
// no limit and no cost: the model is never told of the net. It rides the reply whenever a question was not sent, now
|
|
44
|
+
// or by an earlier decision held for the run.
|
|
45
|
+
export const OUTSTANDING_NOTE = "Not asked in this run. Keep it as an open question with your reason, and continue with what you have read.";
|
|
46
|
+
|
|
39
47
|
const slug = (s) => String(s ?? "").toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "q";
|
|
40
48
|
|
|
41
49
|
const PREVIEW_FIELDS = ["record_id", "mark_text", "classes", "status", "owner_name", "owner_country", "screen_verdict"];
|
|
@@ -57,17 +65,20 @@ const compactProposal = (p) => ({
|
|
|
57
65
|
const inPriority = (p, prio) =>
|
|
58
66
|
(Array.isArray(p?.nice_classes) ? p.nice_classes : []).some((c) => prio.has(String(c).trim()));
|
|
59
67
|
|
|
60
|
-
// A
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
68
|
+
// A BATCH OF SPELLINGS GOES AS THE PLANNER'S LISTS where the register takes one: exact spellings up to
|
|
69
|
+
// `exactOrWidth`, sound-alike ones up to `phoneticOrWidth` within the register's word budget (ruled 2026-10-09
|
|
70
|
+
// for sound-alikes; the exact width is the one the plan's own spelling band already uses), each spelling long
|
|
71
|
+
// enough for the ranked search. A spelling below that floor is its own question, and so is every name of a batch
|
|
72
|
+
// with an owner or on any other predicate, as before. Each list is one question with one receipt row, so a list
|
|
73
|
+
// that crowds or fails takes no other list down with it. PURE.
|
|
64
74
|
function splitBatch(p, names, capabilities) {
|
|
65
75
|
const { terms: _batch, ...rest } = p;
|
|
66
76
|
const single = (t) => ({ ...rest, term: t });
|
|
67
|
-
const
|
|
68
|
-
|
|
77
|
+
const predicate = String(p?.predicate ?? "");
|
|
78
|
+
const width = predicate === "exact" ? planExactOrWidth(capabilities) : predicate === "phonetic" ? planPhoneticOrWidth(capabilities) : 1;
|
|
79
|
+
if (!(width >= 2) || (typeof p?.owner === "string" && p.owner.trim())) return names.map(single);
|
|
69
80
|
const floor = Number(capabilities?.rankedMinLength) || 0;
|
|
70
|
-
const budget = Number(capabilities?.listWordBudget) || Infinity;
|
|
81
|
+
const budget = predicate === "phonetic" ? (Number(capabilities?.listWordBudget) || Infinity) : Infinity;
|
|
71
82
|
const words = (t) => t.split(/\s+/).filter(Boolean).length;
|
|
72
83
|
const folded = (t) => [...t.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, "")].length;
|
|
73
84
|
const out = [];
|
|
@@ -427,9 +438,29 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
427
438
|
if (!offered.length)
|
|
428
439
|
return { type: "text", text: JSON.stringify({ minted: [], reused: [], rejected: [], excluded_house_element: excludedHouse, executed: false }, null, 2) };
|
|
429
440
|
const existingQids = new Set(supp.entries.map((e) => e.qid));
|
|
430
|
-
|
|
441
|
+
let { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
|
|
431
442
|
{ existingQids, capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
|
|
432
443
|
|
|
444
|
+
// ── ONE ANSWER PER QUESTION PER RUN (held-answers.mjs) ───────────────────────────────────────────
|
|
445
|
+
// A question another worker or pass has already answered gets that answer, and one the run has decided not to ask
|
|
446
|
+
// gets that decision: it is not minted, not run, and costs no call. A failed answer is no answer, so it is asked again.
|
|
447
|
+
const held = [];
|
|
448
|
+
try {
|
|
449
|
+
const runDir = dirname(dirname(outPath));
|
|
450
|
+
const index = heldAnswers(runDir, { planRegions, withheld: readWithheldFamilies(runDir) });
|
|
451
|
+
if (index.size) {
|
|
452
|
+
const fresh = [];
|
|
453
|
+
for (const e of minted) {
|
|
454
|
+
const h = index.get(questionKey(e, planRegions));
|
|
455
|
+
if (!h || h.qid === e.qid) { fresh.push(e); continue; }
|
|
456
|
+
held.push({ question: compactProposal(e), decision: h.decision, ...(h.qid ? { answered_by: h.qid, axis: h.axis } : {}),
|
|
457
|
+
...(h.block ? { state: h.block.state ?? null, total_hits: h.block.total_hits ?? null,
|
|
458
|
+
count: h.block.count ?? (Array.isArray(h.block.records) ? h.block.records.length : null), records_preview: preview(h.block.records) } : {}) });
|
|
459
|
+
}
|
|
460
|
+
minted = fresh;
|
|
461
|
+
}
|
|
462
|
+
} catch { /* the held index is an economy: without it every question is asked as before */ }
|
|
463
|
+
|
|
433
464
|
// Field-level romanisation enrichment of a REUSED qid (2026-07-30 review round): the natural retry —
|
|
434
465
|
// a bare non-Latin proposal deferred at the wire, the model re-proposes it WITH the romanisation —
|
|
435
466
|
// reuses the same deterministic qid, and without this the stored bare entry re-executed verbatim and
|
|
@@ -486,7 +517,15 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
486
517
|
renameSync(`${suppPath}.tmp`, suppPath);
|
|
487
518
|
}
|
|
488
519
|
|
|
489
|
-
|
|
520
|
+
// A question this worker re-proposes that already holds an answer is not run again: the answer stands. One with no
|
|
521
|
+
// answer, a failed one, or one this call enriched or gave its markets runs as before.
|
|
522
|
+
let reusedHeld = [];
|
|
523
|
+
try {
|
|
524
|
+
const own = new Map();
|
|
525
|
+
for (const b of JSON.parse(readFileSync(outPath, "utf8"))) if (b?.qid) own.set(String(b.qid), b);
|
|
526
|
+
reusedHeld = reused.filter((q) => { const b = own.get(String(q)); return b && b.error !== true && !enrichedQids.includes(q) && !staleOnDisk; });
|
|
527
|
+
} catch { reusedHeld = []; }
|
|
528
|
+
const qids = [...minted.map((e) => e.qid), ...reused.filter((q) => !reusedHeld.includes(q))];
|
|
490
529
|
let summary = null;
|
|
491
530
|
if (qids.length) {
|
|
492
531
|
const r = await executePlan({ plan_path: suppPath, axis, output_path: outPath, qids }, tctx);
|
|
@@ -497,13 +536,36 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
497
536
|
try { summary = JSON.parse(text); } catch { summary = { raw: text.slice(0, 300) }; }
|
|
498
537
|
}
|
|
499
538
|
|
|
539
|
+
// A FOLLOW-UP THE RUN'S SAFETY NET DID NOT SEND never ran and has no answer (follow-up-searches.mjs). One minted by this
|
|
540
|
+
// call leaves the plan the fold reads, so it is never taken for a planned question that went missing, and is
|
|
541
|
+
// kept as a question not asked: a rejected[] row, which the ask ledger keeps open. One that ran before keeps its
|
|
542
|
+
// place and its answer.
|
|
543
|
+
const outstanding = Array.isArray(summary?.outstanding) ? summary.outstanding.map(String) : [];
|
|
544
|
+
const mintedHere = new Map(minted.map((e) => [e.qid, e]));
|
|
545
|
+
const refusedHere = outstanding.filter((q) => mintedHere.has(q));
|
|
546
|
+
if (refusedHere.length) {
|
|
547
|
+
let cur = supp;
|
|
548
|
+
try { cur = JSON.parse(readFileSync(suppPath, "utf8")); } catch { /* the in-memory plan is what was written */ }
|
|
549
|
+
const gone = new Set(refusedHere);
|
|
550
|
+
cur = withRejected({ ...cur, entries: (Array.isArray(cur?.entries) ? cur.entries : []).filter((e) => !gone.has(e?.qid)) },
|
|
551
|
+
// The markets and goods ride on the row: the ask ledger keeps one ask per (issue, proposal), and the compact
|
|
552
|
+
// proposal alone would fold the same question refused for several markets into one ask naming none.
|
|
553
|
+
refusedHere.map((q) => { const e = mintedHere.get(q); return { issue: OUTSTANDING_REASON, proposal: { ...compactProposal(e),
|
|
554
|
+
...(Array.isArray(e?.regions) && e.regions.length ? { regions: e.regions.map(String).slice(0, 200) } : {}),
|
|
555
|
+
...(Array.isArray(e?.goods_text) && e.goods_text.length ? { goods_text: e.goods_text.map((g) => String(g).slice(0, 80)).slice(0, 20) } : {}) } }; }),
|
|
556
|
+
{ ts: new Date().toISOString(), origin: "propose-tool" });
|
|
557
|
+
mkdirSync(dirname(suppPath), { recursive: true });
|
|
558
|
+
writeFileSync(`${suppPath}.tmp`, JSON.stringify(cur, null, 2) + "\n");
|
|
559
|
+
renameSync(`${suppPath}.tmp`, suppPath);
|
|
560
|
+
}
|
|
561
|
+
|
|
500
562
|
// read the results BACK FROM THE BAND — the reasoning payload is never model-transcribed
|
|
501
563
|
const results = {};
|
|
502
564
|
try {
|
|
503
565
|
const band = JSON.parse(readFileSync(outPath, "utf8"));
|
|
504
566
|
const blocks = Array.isArray(band) ? band : [];
|
|
505
567
|
for (const b of blocks) {
|
|
506
|
-
if (!b || typeof b !== "object" || !b.qid || !qids.includes(b.qid)) continue;
|
|
568
|
+
if (!b || typeof b !== "object" || !b.qid || !(qids.includes(b.qid) || reusedHeld.includes(b.qid))) continue;
|
|
507
569
|
results[b.qid] = {
|
|
508
570
|
state: b.state ?? null,
|
|
509
571
|
total_hits: b.total_hits ?? null,
|
|
@@ -517,5 +579,5 @@ export async function proposeSupplemental(params, tctx, deps) {
|
|
|
517
579
|
}
|
|
518
580
|
} catch { /* band unreadable — the executor summary still crosses */ }
|
|
519
581
|
|
|
520
|
-
return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
|
|
582
|
+
return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid).filter((q) => !refusedHere.includes(q)), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(outstanding.length ? { outstanding } : {}), ...(held.length ? { held } : {}), ...(outstanding.length || held.some((h) => h.decision === "outstanding") ? { outstanding_note: OUTSTANDING_NOTE } : {}), ...(reusedHeld.length ? { held_answer_kept: reusedHeld } : {}), ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
|
|
521
583
|
}
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.5.0-beta.
|
|
5
|
+
"version": "0.5.0-beta.1",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|