clearotron 0.4.1 → 0.5.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/start.mjs +33 -8
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +23 -0
- package/driver/ask-ledger.mjs +86 -15
- package/driver/band-shape.mjs +61 -11
- package/driver/band-size.mjs +10 -0
- package/driver/close-verify.mjs +29 -2
- package/driver/common-law-coverage-status.mjs +1 -1
- package/driver/connotation-search.mjs +7 -9
- package/driver/contract-e3-backlog.mjs +34 -34
- package/driver/contract-e3-baseline.json +5 -5
- package/driver/contract-vocabulary.mjs +12 -12
- package/driver/corrections-delivered.mjs +113 -0
- package/driver/corrections-feedforward.mjs +16 -6
- package/driver/corrective-arm.mjs +11 -8
- package/driver/coverage-form.mjs +3 -0
- package/driver/coverage-union.mjs +11 -2
- package/driver/digest-queue.mjs +1 -1
- package/driver/disposition-call.mjs +1 -1
- package/driver/document-coverage.mjs +3 -2
- package/driver/effective-scope.mjs +2 -2
- package/driver/engine/mcp/band-server.mjs +77 -13
- package/driver/engine/mcp/dispositions-server.mjs +2 -2
- package/driver/engine/mcp/recording-server.mjs +10 -6
- package/driver/findings-model.mjs +16 -4
- package/driver/floor-duty.mjs +9 -0
- package/driver/gate-metrics.mjs +7 -1
- package/driver/gateway.mjs +28 -14
- package/driver/goods-evidence.mjs +295 -0
- package/driver/knockout-assess-record.mjs +1 -1
- package/driver/narrative-refutation-record.mjs +40 -2
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +1945 -314
- package/driver/placement-decisions.mjs +243 -0
- package/driver/placement-form.mjs +42 -8
- package/driver/placement-union.mjs +14 -6
- package/driver/provider-usage.mjs +10 -7
- package/driver/publish/audit-from-spine.mjs +5 -2
- package/driver/publish/index.mjs +19 -4
- package/driver/publish/xlsx.mjs +6 -6
- package/driver/recall-reconciliation.mjs +15 -5
- package/driver/record-carry.mjs +70 -6
- package/driver/record-discard.mjs +7 -1
- package/driver/record-fold.mjs +14 -0
- package/driver/record-goods.mjs +101 -5
- package/driver/register-plan.mjs +89 -0
- package/driver/registry-fidelity.mjs +34 -3
- package/driver/remedy-accounting.mjs +52 -14
- package/driver/repair-composers.mjs +265 -105
- package/driver/repair-scope.mjs +327 -0
- package/driver/repair-targets.mjs +92 -0
- package/driver/result-noun-fields.mjs +3 -3
- package/driver/screen-gate.mjs +96 -0
- package/driver/skills/clearance-common-law/SKILL.md +8 -0
- package/driver/skills/clearance-register/SKILL.md +5 -5
- package/driver/skills/clearance-register/digest.md +14 -14
- package/driver/skills/clearance-register/unit.md +3 -3
- package/driver/skills/clearance-search/delivery-contract.md +2 -58
- package/driver/skills/clearance-search/report-prose.md +12 -3
- package/driver/skills/clearance-search/risk-framework-triage.md +1 -1
- package/driver/skills/clearance-search/synthesis-rules.md +10 -13
- package/driver/skills/clearance-variants/SKILL.md +4 -4
- package/driver/skills/knockout-assess/SKILL.md +9 -14
- package/driver/skills/knockout-frame/SKILL.md +5 -5
- package/driver/skills/narrative-refutation/SKILL.md +1 -1
- package/driver/skills/placement-inquiry/SKILL.md +1 -1
- package/driver/skills/whatif-memo/SKILL.md +1 -1
- package/driver/stage-context.mjs +2 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/stages.mjs +95 -60
- package/driver/suite-census.json +367 -19
- package/driver/synthesis-record.mjs +310 -8
- package/driver/terminal-clamp.mjs +53 -2
- package/driver/verify-knockout.mjs +22 -4
- package/driver/verify.mjs +23 -3
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/driver.mjs +5 -0
- package/mcp-server/lib/events.mjs +25 -4
- package/mcp-server/lib/findings.mjs +4 -2
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/jx/src/core.js +3 -4
- package/providers/jx/src/judge.js +2 -2
- package/providers/jx/src/nativeread.js +1 -2
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e-first-time.mjs +29 -12
package/driver/record-carry.mjs
CHANGED
|
@@ -702,6 +702,61 @@ export function mintRecordCarryDoubts(artifact, { max = 25 } = {}) {
|
|
|
702
702
|
return { doubts, minted: doubts.length, omitted: Math.max(0, all.length - doubts.length) };
|
|
703
703
|
}
|
|
704
704
|
|
|
705
|
+
/**
|
|
706
|
+
* THE RECORD-CARRY DOUBTS AGAIN, FROM A TRACE WRITTEN AGAIN after a repair. A doubt of this family names one record
|
|
707
|
+
* under a POSITIONAL id, and the closure step's verdicts are applied by id, so a closure recorded against an id must
|
|
708
|
+
* never move onto another record:
|
|
709
|
+
* - a record still unreasoned keeps its doubt exactly as it stands: id, status and ending;
|
|
710
|
+
* - a record newly unreasoned gets a new doubt, under an id past every one this family gave, stitched by `stitch` as
|
|
711
|
+
* a fresh doubt is (in code; nothing is dispatched);
|
|
712
|
+
* - a record the new trace POSITIVELY reasons, a row for it that is not unreasoned, loses its doubt;
|
|
713
|
+
* - a record the new trace does not list at all keeps its doubt as it stands: an absence is not a ground.
|
|
714
|
+
* A trace that is missing or could not be computed (no readable band, among other causes) says nothing about any
|
|
715
|
+
* record, so it removes nothing and adds nothing: every doubt stands as the pass raised it (`unavailable: true`).
|
|
716
|
+
* Every other family's doubts stay exactly where they stood. The cap (`max`) limits only the new doubts: a record that
|
|
717
|
+
* keeps its doubt keeps it wherever the new trace lists it, or where it does not. Returns { doubts, fresh, dropped,
|
|
718
|
+
* minted, omitted, unavailable }: the ids of the new and the dropped doubts, the family's size, the records without a
|
|
719
|
+
* ground the cap left out, and whether the trace could be read at all. PURE.
|
|
720
|
+
*/
|
|
721
|
+
export function recordCarryDoubtsAgain(doubts, trace, { stitch = (ds) => ds, max = 25 } = {}) {
|
|
722
|
+
const FAMILY = /^doubt:record-carry:unreasoned:(\d+)$/;
|
|
723
|
+
const all = Array.isArray(doubts) ? doubts : [];
|
|
724
|
+
const inFamily = (d) => FAMILY.test(String(d?.id ?? ""));
|
|
725
|
+
const uriOf = (d) => String(d?.subject?.uris?.[0] ?? "").toLowerCase();
|
|
726
|
+
const old = all.filter(inFamily);
|
|
727
|
+
if (trace?.computable !== true) return { doubts: all, fresh: [], dropped: [], minted: old.length, omitted: 0, unavailable: true };
|
|
728
|
+
const oldByUri = new Map(old.filter((d) => uriOf(d)).map((d) => [uriOf(d), d]));
|
|
729
|
+
// Minted whole, then capped: a record that keeps its doubt keeps it wherever the new trace lists it, and the cap
|
|
730
|
+
// limits only the new doubts, filled in the trace's order up to what the kept ones leave.
|
|
731
|
+
const mint = mintRecordCarryDoubts(trace, { max: Infinity });
|
|
732
|
+
const unreasonedUris = new Set(mint.doubts.map(uriOf));
|
|
733
|
+
const listed = new Set((Array.isArray(trace?.rows) ? trace.rows : []).map((r) => String(r?.uri ?? "").toLowerCase()).filter(Boolean));
|
|
734
|
+
const reasoned = (uri) => listed.has(uri) && !unreasonedUris.has(uri);
|
|
735
|
+
const carried = old.filter((d) => !unreasonedUris.has(uriOf(d)) && !reasoned(uriOf(d))); // not listed: kept as it stands
|
|
736
|
+
const keptCount = mint.doubts.filter((d) => oldByUri.has(uriOf(d))).length + carried.length;
|
|
737
|
+
let room = Math.max(0, max - keptCount);
|
|
738
|
+
let next = old.reduce((n, d) => Math.max(n, Number(FAMILY.exec(d.id)[1])), 0);
|
|
739
|
+
const family = [], fresh = [];
|
|
740
|
+
for (const d of mint.doubts) {
|
|
741
|
+
const prior = oldByUri.get(uriOf(d));
|
|
742
|
+
if (prior) family.push(prior);
|
|
743
|
+
else if (room > 0) { room--; const f = { ...d, id: `doubt:record-carry:unreasoned:${++next}` }; fresh.push(f); family.push(f); }
|
|
744
|
+
}
|
|
745
|
+
family.push(...carried);
|
|
746
|
+
const stitched = fresh.length ? new Map(stitch(fresh).map((d) => [d.id, d])) : new Map();
|
|
747
|
+
const members = family.map((d) => stitched.get(d.id) ?? d);
|
|
748
|
+
const keptIds = new Set(members.map((d) => d.id));
|
|
749
|
+
const out = [];
|
|
750
|
+
let placed = false;
|
|
751
|
+
for (const d of all) {
|
|
752
|
+
if (!inFamily(d)) { out.push(d); continue; }
|
|
753
|
+
if (!placed) { out.push(...members); placed = true; }
|
|
754
|
+
}
|
|
755
|
+
if (!placed) out.push(...members);
|
|
756
|
+
return { doubts: out, fresh: fresh.map((d) => d.id), dropped: old.filter((d) => !keptIds.has(d.id)).map((d) => d.id),
|
|
757
|
+
minted: members.length, omitted: Math.max(0, mint.doubts.length - (members.length - carried.length)), unavailable: false };
|
|
758
|
+
}
|
|
759
|
+
|
|
705
760
|
/**
|
|
706
761
|
* The one-line reader answer to "where did this record stop, and why". Takes the artifact and a mark
|
|
707
762
|
* text or uri; returns the matching rows. The acceptance question for — "does it say why 澜珀 did
|
|
@@ -915,12 +970,7 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
|
|
|
915
970
|
// So the carry rows are the population — they are where a stated drop is RECORDED — and the
|
|
916
971
|
// reconciliation is demoted to optional corroboration. Measured on that delivery, the correct
|
|
917
972
|
// population is 70 rows over 34 distinct marks, all `digest:reasoned-negative`, and it contains both.
|
|
918
|
-
|
|
919
|
-
const population = carryRows.filter((r) => r?.uri
|
|
920
|
-
&& r.reason_source === "step-stated" // the sibling owns step-silent; step-structural is mechanical
|
|
921
|
-
&& !arrived.has(r.reach)); // arrived, or arrived somewhere visible, is not a divergence
|
|
922
|
-
|
|
923
|
-
// Kept for corroboration only. `ended` no longer gates anything; where the reconciliation DOES name a
|
|
973
|
+
// Kept for corroboration. `ended` gates nothing but one exclusion below; where the reconciliation DOES name a
|
|
924
974
|
// position it agrees with, that is recorded on the row so a reader can weigh it.
|
|
925
975
|
const ended = [];
|
|
926
976
|
for (const bucket of ["top_slice", "residual"]) {
|
|
@@ -930,6 +980,20 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
|
|
|
930
980
|
}
|
|
931
981
|
}
|
|
932
982
|
const endedUris = new Set(ended.map((e) => e.uri));
|
|
983
|
+
const arrived = new Set(["finding", "findings-surface"]);
|
|
984
|
+
// PLACEMENT'S OWN SET-ASIDE IS NOT A LOST FINDING. A record placement set aside, by name or by its group's row,
|
|
985
|
+
// leaves the placement seam with the step's ground before the digest ever sees it: the ruled method's decision,
|
|
986
|
+
// recorded where it was made. Read as a stated drop it was listed as a divergence pointing at another artifact,
|
|
987
|
+
// and on a crowded band those rows buried the few this check is for. It stays in the population where the digest
|
|
988
|
+
// ended its position as a finding, because then the record did vanish: in the reconciliation, or in the finding
|
|
989
|
+
// rows the digest's own typed calls recorded, which the reconciliation's silence cannot erase.
|
|
990
|
+
const digestFound = new Set((Array.isArray(digestFindingUris) ? digestFindingUris : []).map(lc));
|
|
991
|
+
const placementSetAside = (r) => r.stopped_at === "placement" && r.reason === "placement:set-aside"
|
|
992
|
+
&& !endedUris.has(lc(r.uri)) && !digestFound.has(lc(r.uri));
|
|
993
|
+
const population = carryRows.filter((r) => r?.uri
|
|
994
|
+
&& r.reason_source === "step-stated" // the sibling owns step-silent; step-structural is mechanical
|
|
995
|
+
&& !arrived.has(r.reach) // arrived, or arrived somewhere visible, is not a divergence
|
|
996
|
+
&& !placementSetAside(r));
|
|
933
997
|
const byUri = new Map();
|
|
934
998
|
for (const r of carryRows) if (r?.uri) byUri.set(lc(r.uri), r);
|
|
935
999
|
|
|
@@ -92,8 +92,13 @@ const clip = (s, n = 300) => {
|
|
|
92
92
|
* not name cannot be distinguished between considered-and-not-selected and never reached at all. Those
|
|
93
93
|
* rows carry `verdict: "discarded"` with `reason_source: "step-structural"` and a reason naming the
|
|
94
94
|
* incompletion, so the fact survives; they are never silently folded into ordinary drops.
|
|
95
|
+
*
|
|
96
|
+
* A SKIPPED PASS DECIDED NOTHING. A resumed run skips a stage whose answer is on disk, and that answer is an
|
|
97
|
+
* earlier pass's. Where `prior` (the folded ledger) already holds that earlier pass's row for a record at this
|
|
98
|
+
* seam, the skipped pass writes none: under the winner rule an incompletion written over it erased every ground
|
|
99
|
+
* the step had given. A record no earlier pass spoke about is still recorded as not reached.
|
|
95
100
|
*/
|
|
96
|
-
export function seamRows({ seam, stage, trigger = null, pass = 1, completed = true,
|
|
101
|
+
export function seamRows({ seam, stage, trigger = null, pass = 1, completed = true, skipped = false, prior = null,
|
|
97
102
|
saw = [], carried = [], reasonFor = null, evidence = "" } = {}) {
|
|
98
103
|
if (!DISCARD_SEAMS.includes(seam)) return [];
|
|
99
104
|
const carriedSet = new Set();
|
|
@@ -112,6 +117,7 @@ export function seamRows({ seam, stage, trigger = null, pass = 1, completed = tr
|
|
|
112
117
|
continue;
|
|
113
118
|
}
|
|
114
119
|
if (!completed) {
|
|
120
|
+
if (skipped && prior?.byUri?.get?.(uri)?.[seam]) continue;
|
|
115
121
|
rows.push({
|
|
116
122
|
seam, stage, trigger, pass, uri, verdict: "discarded",
|
|
117
123
|
reason: `${seam}:stage-incomplete`, reason_source: "step-structural",
|
package/driver/record-fold.mjs
CHANGED
|
@@ -153,6 +153,20 @@ export function modelRecordPath(runDir, findingsPath) {
|
|
|
153
153
|
return readFold(runDir)?.map && existsSync(at) ? at : findingsPath;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The model's ordinal behind a finding of findings.json as it stands, given the fold state `readFold` returns.
|
|
158
|
+
* With no fold in force the file is the model's record, so the ordinal is its own. Under a fold a folded finding
|
|
159
|
+
* IS the object of the member the fold kept, so that member is the answer. Null when a fold is in force and its
|
|
160
|
+
* map does not name the finding: a caller then leaves it alone rather than guessing. PURE.
|
|
161
|
+
*/
|
|
162
|
+
export function keptModelOrdinal(state, ordinal) {
|
|
163
|
+
if (!state?.map) return ordinal;
|
|
164
|
+
const members = state.map.foldedToModel?.[String(ordinal)];
|
|
165
|
+
if (!Array.isArray(members) || !members.length) return null;
|
|
166
|
+
if (members.length === 1) return members[0];
|
|
167
|
+
return (state.merges ?? []).find((m) => members.includes(m?.kept))?.kept ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
156
170
|
/** The fold as it stands, to put back if a pass that changed it is rolled back. Never throws. */
|
|
157
171
|
export function snapshotFold(runDir) {
|
|
158
172
|
const state = readFold(runDir);
|
package/driver/record-goods.mjs
CHANGED
|
@@ -77,12 +77,25 @@ export function recordGoods(body, { english = true } = {}) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
if (out.length === 0 && Array.isArray(b?._raw?.classifications)) {
|
|
80
|
-
for (const c of b._raw.classifications)
|
|
80
|
+
for (const c of b._raw.classifications) {
|
|
81
|
+
const at = out.length;
|
|
82
|
+
push([c?.nice_class], c?.goods_services_text, null, isEnglish(c?.goods_services_language) || (!tagged(c?.goods_services_language) && english));
|
|
83
|
+
// The register's own flag, on the full record it serves (Signa, Retrieve Trademark, the classification
|
|
84
|
+
// object's `goods_services_text_truncated`): true is a cut, false is whole, and no flag establishes nothing.
|
|
85
|
+
if (out.length > at) {
|
|
86
|
+
if (c?.goods_services_text_truncated === true) out[at].cut = true;
|
|
87
|
+
else if (c?.goods_services_text_truncated !== false) out[at].unknown = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
81
90
|
}
|
|
82
91
|
if (out.length === 0 && b.goodsAndServices != null) {
|
|
83
92
|
const gs = b.goodsAndServices;
|
|
93
|
+
// One string is the register's own statement as this install's US index holds it, whole (uspto-local). A list
|
|
94
|
+
// is a register whose full-record operation this tree documents nowhere (EUIPO and the free tier): its goods
|
|
95
|
+
// are read, and their completeness is not established.
|
|
84
96
|
if (typeof gs === "string") push([], gs, null, english);
|
|
85
97
|
else if (Array.isArray(gs)) {
|
|
98
|
+
const from = out.length;
|
|
86
99
|
for (const g of gs) {
|
|
87
100
|
if (typeof g === "string") { push([], g, null, english); continue; }
|
|
88
101
|
const d = g?.description;
|
|
@@ -91,11 +104,35 @@ export function recordGoods(body, { english = true } = {}) {
|
|
|
91
104
|
push([g?.classNumber ?? g?.class ?? g?.niceClass ?? g?.nice_class], textOf(chosen ?? g?.text ?? g?.goods), null,
|
|
92
105
|
Boolean(en) || (Array.isArray(d) ? !tagged(d[0]?.language) && english : english));
|
|
93
106
|
}
|
|
107
|
+
for (let i = from; i < out.length; i++) out[i].unknown = true;
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
110
|
return out;
|
|
97
111
|
}
|
|
98
112
|
|
|
113
|
+
/**
|
|
114
|
+
* WHAT A HELD RECORD'S GOODS REST ON: the operation its body came from, and whether that body supplies the goods
|
|
115
|
+
* field at all. PURE. `operation` is read off the body's shape, as `recordGoods` reads it:
|
|
116
|
+
* · `record-text` — Clarivate's record text, the full record (its statements are whole: the accepted provenance);
|
|
117
|
+
* · `retrieve` — Signa's Retrieve Trademark, the full record, each statement with the register's own flag;
|
|
118
|
+
* · `bulk-register` — the register's own statement as this install's US index holds it, whole;
|
|
119
|
+
* · `undocumented` — any other shape: its completeness is not established.
|
|
120
|
+
* `field` is `listed` (the field is there with goods), `empty` (there, listing none) or `none` (not there at
|
|
121
|
+
* all): a body whose goods field is missing, or that will not parse, is not one that lists no goods.
|
|
122
|
+
*/
|
|
123
|
+
export function recordGoodsProvenance(body) {
|
|
124
|
+
const b = Array.isArray(body) ? body[0] : body;
|
|
125
|
+
if (!b || typeof b !== "object") return { operation: "undocumented", field: "none" };
|
|
126
|
+
const said = (v) => String(textOf(v) ?? "").trim() !== "";
|
|
127
|
+
if (Array.isArray(b.goodsServices))
|
|
128
|
+
return { operation: "record-text", field: b.goodsServices.some((g) => said(g?.description) || said(g?.translation)) ? "listed" : "empty" };
|
|
129
|
+
if (Array.isArray(b?._raw?.classifications))
|
|
130
|
+
return { operation: "retrieve", field: b._raw.classifications.some((c) => said(c?.goods_services_text)) ? "listed" : "empty" };
|
|
131
|
+
if (typeof b.goodsAndServices === "string") return { operation: "bulk-register", field: b.goodsAndServices.trim() ? "listed" : "empty" };
|
|
132
|
+
if (b.goodsAndServices != null) return { operation: "undocumented", field: recordGoods(b).length ? "listed" : "empty" };
|
|
133
|
+
return { operation: "undocumented", field: "none" };
|
|
134
|
+
}
|
|
135
|
+
|
|
99
136
|
// THE RAW GOODS LIST SAYS NOTHING THE NORMALISED ONE DOES NOT. A Clarivate record holds its goods twice: as the
|
|
100
137
|
// register wrote them (`_raw.goodsServices.intClassDescriptions`) and as the provider maps them, item for item,
|
|
101
138
|
// into `goodsServices` beside it (providers/clarivate/src/core.js `normalizeGoodsServices`). On a crowded test
|
|
@@ -163,6 +200,59 @@ export function bandGoodsIndex(runDir) {
|
|
|
163
200
|
/** The goods of one band row from an index, or null when the run holds none for it. PURE. */
|
|
164
201
|
export const goodsFor = (index, record) => index?.get(normalizeRecordUri(record?.record_id)) ?? null;
|
|
165
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The goods a band row holds itself, where the register's search answer carries them: Signa sends each record's
|
|
205
|
+
* class statements on the search row (`raw.classifications`) and flags a statement it cut. Each statement is read as
|
|
206
|
+
* a held body's is (`recordGoods`) and marked `cut: true` where the register cut it, so a statement that stops early
|
|
207
|
+
* is never read as the whole of its class. null when the row holds none. PURE.
|
|
208
|
+
*
|
|
209
|
+
* WHOLE ONLY WHERE THE ANSWER SAYS SO. The search answer our request asks for is the register's default, which it
|
|
210
|
+
* documents as "the first 280 characters" of each statement (docs.signa.so, Search Trademarks, "Response options",
|
|
211
|
+
* `include`: "`full_goods_services` returns the full goods and services text instead of the first 280
|
|
212
|
+
* characters"); the request never asks for the full text. So a statement shorter than 280 characters is whole, a
|
|
213
|
+
* flagged one is cut, and an unflagged one of 280 characters or more is marked `unknown: true`: its completeness is
|
|
214
|
+
* not established, and missing words in it establish nothing.
|
|
215
|
+
*/
|
|
216
|
+
export const DEFAULT_ANSWER_CUT = 280;
|
|
217
|
+
export function rowGoods(row) {
|
|
218
|
+
const classes = Array.isArray(row?.raw?.classifications) ? row.raw.classifications : null;
|
|
219
|
+
if (!classes) return null;
|
|
220
|
+
const english = untaggedIsEnglish(normalizeRecordUri(row?.record_id));
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const c of classes) {
|
|
223
|
+
const [g] = recordGoods({ _raw: { classifications: [c] } }, { english });
|
|
224
|
+
if (!g) continue;
|
|
225
|
+
const { cut: _cut, unknown: _unknown, ...whole } = g; // the row's own rule below, not the full record's
|
|
226
|
+
if (c?.goods_services_text_truncated === true) out.push({ ...whole, cut: true });
|
|
227
|
+
else if ([...String(c?.goods_services_text ?? "")].length >= DEFAULT_ANSWER_CUT) out.push({ ...whole, unknown: true });
|
|
228
|
+
else out.push(whole);
|
|
229
|
+
}
|
|
230
|
+
return out.length ? out : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Was a statement of these goods cut short by the register? A record whose statements match no word, while one of
|
|
235
|
+
* them stops where the register cut it, is unknown to the goods test, not a mismatch: the words cut off may be the
|
|
236
|
+
* client's. Only goods read off a band row carry the mark (`rowGoods`); a held body's statements are whole. PURE.
|
|
237
|
+
*/
|
|
238
|
+
export const statementCut = (goods) => (goods ?? []).some((g) => g?.cut === true);
|
|
239
|
+
|
|
240
|
+
/** Is a statement of these goods one whose completeness is not established (`rowGoods`, `recordGoods`)? PURE. */
|
|
241
|
+
export const completenessUnknown = (goods) => (goods ?? []).some((g) => g?.unknown === true);
|
|
242
|
+
/** Could words cut off, or not shown, from these goods be the client's? Then a statement matching none is unknown. PURE. */
|
|
243
|
+
const notWhole = (goods) => statementCut(goods) || completenessUnknown(goods);
|
|
244
|
+
|
|
245
|
+
/** How many records rest on each kind of goods evidence: a held body, a row's whole statements, a row's statements cut short or not established whole, or none. PURE. */
|
|
246
|
+
export function goodsEvidence(records, goods) {
|
|
247
|
+
const out = { held: 0, complete: 0, cut: 0, unknown: 0, none: 0 };
|
|
248
|
+
for (const r of Array.isArray(records) ? records : []) {
|
|
249
|
+
if (goods?.held?.(r)) { out.held++; continue; }
|
|
250
|
+
const g = goods ? goods.of(r) : null;
|
|
251
|
+
if (!g) out.none++; else if (statementCut(g)) out.cut++; else if (completenessUnknown(g)) out.unknown++; else out.complete++;
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
166
256
|
const fold = (s) => foldDiacritics(String(s ?? "")).toLowerCase();
|
|
167
257
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
168
258
|
|
|
@@ -206,7 +296,7 @@ export function goodsWordCounter(words, { stems = false } = {}) {
|
|
|
206
296
|
if (!res.length || !readable.length) return null;
|
|
207
297
|
const text = fold(readable.map((g) => (g.alt ? `${g.text}\n${g.alt}` : g.text)).join("\n"));
|
|
208
298
|
const n = res.filter((re) => re.test(text)).length;
|
|
209
|
-
return n === 0 && classUnread(goods) ? null : n;
|
|
299
|
+
return n === 0 && (classUnread(goods) || notWhole(goods)) ? null : n;
|
|
210
300
|
};
|
|
211
301
|
}
|
|
212
302
|
|
|
@@ -238,7 +328,7 @@ export function goodsWordsMatched(words, { stems = false } = {}) {
|
|
|
238
328
|
return (goods) => {
|
|
239
329
|
if (!list.length || !(goods ?? []).some((g) => g.en !== false)) return null;
|
|
240
330
|
const hit = tests.filter(([, t]) => t(goods) === 1).map(([w]) => w);
|
|
241
|
-
return hit.length === 0 && classUnread(goods) ? null : hit;
|
|
331
|
+
return hit.length === 0 && (classUnread(goods) || notWhole(goods)) ? null : hit;
|
|
242
332
|
};
|
|
243
333
|
}
|
|
244
334
|
|
|
@@ -269,8 +359,12 @@ export function instructedOrder(P, job = null) {
|
|
|
269
359
|
* search asked for beside the one the variants stage named. The client's markets come from the caller:
|
|
270
360
|
* this module is loaded by the band tool server, which reads no account settings. IMPURE (reads the run).
|
|
271
361
|
* Any part that cannot be read is simply absent, and the shape sorts without it.
|
|
362
|
+
*
|
|
363
|
+
* A record's goods are its held body's, and where the run holds no body, the statements its band row carries
|
|
364
|
+
* (`rowGoods`). `held` says which: a line's goods are "on file" only where a body is held, because that is the
|
|
365
|
+
* record `band_record` can open. `records` are the band's, so the shape tests goods whenever either is held.
|
|
272
366
|
*/
|
|
273
|
-
export function bandShapeGoods(P) {
|
|
367
|
+
export function bandShapeGoods(P, records = []) {
|
|
274
368
|
let model = null;
|
|
275
369
|
try { if (existsSync(P.variantManifestModel)) model = parseVariantManifestModel(readFileSync(P.variantManifestModel, "utf8")); } catch { model = null; }
|
|
276
370
|
let searched = "";
|
|
@@ -285,7 +379,9 @@ export function bandShapeGoods(P) {
|
|
|
285
379
|
const words = product ? model.product_words : Array.isArray(model?.goods_words) ? model.goods_words : [];
|
|
286
380
|
const stems = true; // the word list the shape groups on is matched as stems, whichever source it came from
|
|
287
381
|
const hits = goodsWordCounter(words, { stems });
|
|
288
|
-
const
|
|
382
|
+
const held = (r) => goodsFor(index, r);
|
|
383
|
+
const rowsHold = Array.isArray(records) && records.some((r) => r && typeof r === "object" && rowGoods(r));
|
|
384
|
+
const goods = index.size || rowsHold ? { of: (r) => held(r) ?? rowGoods(r), held: (r) => held(r) != null, hits, matched: goodsWordsMatched(words, { stems }) } : null;
|
|
289
385
|
const goodsTest = { source: product ? "product_words" : words.length ? "goods_words" : "none", words };
|
|
290
386
|
return { goods, goodsTest, goodsWordCount: words.length, dominantTerm: searched || manifestDominant, manifestDominant };
|
|
291
387
|
}
|
package/driver/register-plan.mjs
CHANGED
|
@@ -2155,6 +2155,95 @@ export function foldSupplementalEntries(plan, entries) {
|
|
|
2155
2155
|
};
|
|
2156
2156
|
}
|
|
2157
2157
|
|
|
2158
|
+
/**
|
|
2159
|
+
* THE WAITING FAMILIES THE READING TURN WITHHELD, keyed by the question each one asks.
|
|
2160
|
+
*
|
|
2161
|
+
* A wider family waits in the plan until the reading turn asks for it, and the turn records each family it
|
|
2162
|
+
* leaves unasked as withheld-by-judgment, with its reason (withheld-families.mjs). The fold above lets a
|
|
2163
|
+
* question through when it matches a waiting family, because the turn's own ask of that question is what the
|
|
2164
|
+
* family waits for. That rule cannot tell the turn's ask from anyone else's, and the blind frame comparison's
|
|
2165
|
+
* re-sweep turns its requests into questions by code: its contains question on the mark is such a family, so
|
|
2166
|
+
* it asked families the turn had withheld. This map is what lets the re-sweep tell them apart.
|
|
2167
|
+
*
|
|
2168
|
+
* A family is in it only when all of these hold, because each one missing is a way a question could inherit
|
|
2169
|
+
* a judgment nobody made:
|
|
2170
|
+
* · it waits for the reading turn in this plan: a required row has no withholding to inherit;
|
|
2171
|
+
* · the turn's record names it, on its own axis, with a reason: a family nobody judged is still open;
|
|
2172
|
+
* · it is not released: a release replaces the withholding;
|
|
2173
|
+
* · no plan row that does not wait asks the same question: that row is the one that answers it, and the
|
|
2174
|
+
* fold's duplicate rule names it as the twin.
|
|
2175
|
+
* `withheld` is the record as readWithheldFamilies returns it, `{ [qid]: { axis, reason } }`, and `released`
|
|
2176
|
+
* the set releasedFamilyQids returns. The caller reads both, so this stays PURE.
|
|
2177
|
+
* @returns {Map<string, {qid: string, reason: string}>} question key → the withheld family and its reason
|
|
2178
|
+
*/
|
|
2179
|
+
export function withheldFamilyTwins(plan, withheld = {}, released = new Set()) {
|
|
2180
|
+
const entries = Array.isArray(plan?.entries) ? plan.entries : [];
|
|
2181
|
+
const asked = new Set(entries.filter((e) => e && e.unsupported !== true && !awaitsReadingTurn(e.when))
|
|
2182
|
+
.map((e) => entryQuestionKey(e, plan)).filter(Boolean));
|
|
2183
|
+
const twins = new Map();
|
|
2184
|
+
for (const e of entries) {
|
|
2185
|
+
if (!e?.qid || e.unsupported === true || !awaitsReadingTurn(e.when) || released?.has?.(e.qid)) continue;
|
|
2186
|
+
const w = withheld?.[e.qid];
|
|
2187
|
+
const reason = String(w?.reason ?? "").trim();
|
|
2188
|
+
if (!reason || (w?.axis != null && String(w.axis) !== String(e.axis ?? ""))) continue;
|
|
2189
|
+
const key = entryQuestionKey(e, plan);
|
|
2190
|
+
if (key && !asked.has(key) && !twins.has(key)) twins.set(key, { qid: e.qid, reason });
|
|
2191
|
+
}
|
|
2192
|
+
return twins;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* WHAT A RE-SWEEP THAT ADDS NOTHING STILL OWES ITS AXIS: the axis's entries that have not landed. The executor reads
|
|
2197
|
+
* an empty question list as every entry on the axis, so a fold that added nothing used to repeat every landed
|
|
2198
|
+
* question and, with them, retry the ones still unanswered. These are that retry without the repeats: every entry
|
|
2199
|
+
* on the axis the provider can express (`unsupported` is never sent), a family waiting for the reading turn only once
|
|
2200
|
+
* the turn released it, less every entry the join of these bands counts as executed, a released family's block
|
|
2201
|
+
* included. So it sends a required search that did not land, and also what the old re-send retried among the
|
|
2202
|
+
* unlanded: an entry whose provider error spent its retry ladder (a real call, as before), a capability refusal not
|
|
2203
|
+
* yet accepted as a gap (refused again without a call), and a fringe entry whose guard the executor still holds
|
|
2204
|
+
* (no call). The dispatcher drops the capability gaps the run has already accepted. PURE.
|
|
2205
|
+
* @param {object} plan the frozen register plan
|
|
2206
|
+
* @param {string} axis the axis the re-sweep ran on
|
|
2207
|
+
* @param {object} bandsByAxis the run's register bands, as readRegisterBands returns them
|
|
2208
|
+
* @param {Set<string>} released the families the reading turn released (releasedFamilyQids)
|
|
2209
|
+
*/
|
|
2210
|
+
export function pendingRequiredQids(plan, axis, bandsByAxis = {}, released = new Set()) {
|
|
2211
|
+
const landed = new Set(joinPlanToBands(plan, bandsByAxis, { released }).executed.map((x) => x.qid));
|
|
2212
|
+
return (Array.isArray(plan?.entries) ? plan.entries : [])
|
|
2213
|
+
.filter((e) => e?.qid && e.axis === axis && e.unsupported !== true && (!awaitsReadingTurn(e.when) || released?.has?.(e.qid))
|
|
2214
|
+
&& !landed.has(e.qid))
|
|
2215
|
+
.map((e) => e.qid);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* Refuse the minted entries that repeat a withheld family exactly, before they reach the fold. "Exactly" is
|
|
2220
|
+
* the fold's own question identity (entryQuestionKey: offices resolved, classes, goods, owner, term form), so
|
|
2221
|
+
* the same term asked in other classes, other offices or narrowed by goods is a different question and is
|
|
2222
|
+
* kept. Each refusal is `withheld-by-judgment`, its twin is the family, and it carries the reason on the
|
|
2223
|
+
* family's record, so the closure rule (close-verify.mjs, withheldAs) settles the slice by that judgment:
|
|
2224
|
+
* the slice is never frozen into the plan and never runs, and the family stays waiting, so the reading turn
|
|
2225
|
+
* can still release it.
|
|
2226
|
+
*
|
|
2227
|
+
* NOT A SCREEN IN THE FOLD ITSELF, deliberately. The fold is the funnel for the reading turn's own proposals
|
|
2228
|
+
* as well, and the turn's ask of a family it once withheld is that turn reconsidering, which the waiting rule
|
|
2229
|
+
* exists to let through. Only the re-sweep, which asks on nobody's judgment, calls this.
|
|
2230
|
+
*
|
|
2231
|
+
* Every other entry is KEPT for the fold, which screens it as before. An entry whose qid the plan already
|
|
2232
|
+
* holds is kept too: the fold reads it as a re-proposal of a row that is already there. PURE.
|
|
2233
|
+
* @returns {{ kept: object[], refused: {qid: string, term: string, kind: "withheld-by-judgment", twin: string, reason: string}[] }}
|
|
2234
|
+
*/
|
|
2235
|
+
export function refuseWithheldFamilies(entries, twins, plan) {
|
|
2236
|
+
const inPlan = new Set((plan?.entries ?? []).map((e) => e?.qid));
|
|
2237
|
+
const kept = [], refused = [];
|
|
2238
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
2239
|
+
const key = e?.qid && !inPlan.has(e.qid) ? entryQuestionKey(e, plan) : null;
|
|
2240
|
+
const twin = key ? twins?.get?.(key) : undefined;
|
|
2241
|
+
if (!twin) { kept.push(e); continue; }
|
|
2242
|
+
refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "withheld-by-judgment", twin: twin.qid, reason: twin.reason });
|
|
2243
|
+
}
|
|
2244
|
+
return { kept, refused };
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2158
2247
|
/**
|
|
2159
2248
|
* The store decision (pure — the attach step does only IO around this): given the slug store's
|
|
2160
2249
|
* latest plan (or null) and a fresh compile, decide reuse / extend / mint.
|
|
@@ -105,6 +105,32 @@ let lastLedgerError = null;
|
|
|
105
105
|
export const ledgerReadError = () => lastLedgerError;
|
|
106
106
|
const noteLedger = (r) => { if (r.error) lastLedgerError = r.error; return r; };
|
|
107
107
|
|
|
108
|
+
// A RECORD BODY THAT CARRIES NO RECORD IS NOT A RECORD. A body counts as the record only where some field beside
|
|
109
|
+
// its identity and its plumbing holds a value: a mark, a status, an owner, a class list, goods, a date. An object
|
|
110
|
+
// holding nothing else is a placeholder: it satisfies no evidence duty, and it never replaces a body that carries
|
|
111
|
+
// the record. The normalising registers write their identity on every record, an empty one included: the provider,
|
|
112
|
+
// the office, the numbers the request asked for, a status class of "unknown", the link minted from them, and the
|
|
113
|
+
// underscored provenance and raw copy. None of those is the record. A value counts only where something in it holds
|
|
114
|
+
// one, so an object of nulls, or a list of empty objects, is no content either.
|
|
115
|
+
const RECORD_IDENTITY_KEYS = new Set(["uri", "record_id", "id", "guid", "serial", "serialNumber", "object", "provider", "office", "statusClass", "status_class",
|
|
116
|
+
"applicationNumber", "registrationNumber", "resolved_link", "officeUrl"]);
|
|
117
|
+
const holdsValue = (v, depth = 0) => {
|
|
118
|
+
if (v == null) return false;
|
|
119
|
+
if (typeof v === "string") return Boolean(v.trim());
|
|
120
|
+
if (depth > 8) return true;
|
|
121
|
+
if (Array.isArray(v)) return v.some((x) => holdsValue(x, depth + 1));
|
|
122
|
+
if (typeof v === "object") return Object.values(v).some((x) => holdsValue(x, depth + 1));
|
|
123
|
+
return true;
|
|
124
|
+
};
|
|
125
|
+
/** Does this body carry the record's own content, rather than only an identity? PURE. */
|
|
126
|
+
export function recordBodyHasContent(body) {
|
|
127
|
+
const b = Array.isArray(body) ? body[0] : body;
|
|
128
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) return false;
|
|
129
|
+
return Object.entries(b).some(([k, v]) => !k.startsWith("_") && !RECORD_IDENTITY_KEYS.has(k) && holdsValue(v));
|
|
130
|
+
}
|
|
131
|
+
// A later copy wins, unless it is a placeholder and the record already holds one with content.
|
|
132
|
+
const keepRecord = (map, uri, body) => { if (recordBodyHasContent(body) || !recordBodyHasContent(map.get(uri))) map.set(uri, body); };
|
|
133
|
+
|
|
108
134
|
// Latest fetched body per record URI for the run. Never throws (a missing ledger → empty map).
|
|
109
135
|
//
|
|
110
136
|
// — NO DEFAULT PATH. It used to fall back to `ledgerPath("record")`, the box-global file. Now that
|
|
@@ -118,7 +144,7 @@ export function collectRecordBodies(recordLogPath, runPrefix) {
|
|
|
118
144
|
try { row = JSON.parse(line); } catch { return; }
|
|
119
145
|
if (!runPrefix || !rowMatchesRun(row, runPrefix)) return;
|
|
120
146
|
const body = Array.isArray(row.body) ? row.body[0] : row.body;
|
|
121
|
-
if (row.target && body && typeof body === "object") map
|
|
147
|
+
if (row.target && body && typeof body === "object") keepRecord(map, String(row.target).toLowerCase(), body);
|
|
122
148
|
}));
|
|
123
149
|
return map;
|
|
124
150
|
}
|
|
@@ -128,12 +154,17 @@ export function collectRecordBodies(recordLogPath, runPrefix) {
|
|
|
128
154
|
// provider calls); replaces the "was it on disk?" inference the report's `verified` label rested on.
|
|
129
155
|
export function collectRecordReceipts(recordLogPath, runPrefix) { // — no default; see above
|
|
130
156
|
const map = new Map();
|
|
157
|
+
const real = new Set(); // the records whose receipt is from a body carrying the record, which a placeholder's never replaces
|
|
131
158
|
noteLedger(forEachLedgerLine(recordLogPath, (line) => {
|
|
132
159
|
let row;
|
|
133
160
|
try { row = JSON.parse(line); } catch { return; }
|
|
134
161
|
if (!runPrefix || !rowMatchesRun(row, runPrefix)) return;
|
|
135
162
|
if (!row.target || !row.ts) return;
|
|
136
|
-
|
|
163
|
+
const key = String(row.target).toLowerCase();
|
|
164
|
+
const full = recordBodyHasContent(row.body);
|
|
165
|
+
if (!full && real.has(key)) return;
|
|
166
|
+
if (full) real.add(key);
|
|
167
|
+
map.set(key, { fetched_at: String(row.ts), context: stripGatewayNs(row.sessionKey ?? "") || null });
|
|
137
168
|
}));
|
|
138
169
|
return map;
|
|
139
170
|
}
|
|
@@ -229,7 +260,7 @@ export function assembleRunRecords(runDir, runPrefix,
|
|
|
229
260
|
const fromRunDir = readRecordArtifacts(runDir);
|
|
230
261
|
const fromLedger = collectRecordBodies(recordLogPath, runPrefix);
|
|
231
262
|
const records = new Map(fromRunDir);
|
|
232
|
-
for (const [uri, body] of fromLedger) records
|
|
263
|
+
for (const [uri, body] of fromLedger) keepRecord(records, uri, body);
|
|
233
264
|
// WP-receipts: receipts assemble the same way the records do — artifact-embedded ones (inherited run
|
|
234
265
|
// dir) ∪ this-session ledger rows (fresh fetch wins) — so a re-assembled run dir keeps its proof.
|
|
235
266
|
const receipts = new Map();
|
|
@@ -42,19 +42,23 @@
|
|
|
42
42
|
// re-stated: verifyRegisterDirectiveClose asks the identical question of the same blocks, and two
|
|
43
43
|
// copies of a matcher is how two matchers drift apart.
|
|
44
44
|
|
|
45
|
-
import { blockCollapsed, answeredAs } from "./close-verify.mjs";
|
|
45
|
+
import { blockCollapsed, answeredAs, withheldAs } from "./close-verify.mjs";
|
|
46
46
|
import { reopenKey } from "./frame-diff-model.mjs";
|
|
47
47
|
|
|
48
48
|
export const REMEDY_ACCOUNTING_SCHEMA_VERSION = 1;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* The
|
|
51
|
+
* The six term classes. EXACTLY ONE per remedy term, decided in the fixed order in classifyRemedyTerm.
|
|
52
52
|
*
|
|
53
|
-
* found — every slice landed and the register returned material (records, or a
|
|
54
|
-
* too large to enumerate). The term is answered.
|
|
53
|
+
* found — every slice that ran landed and the register returned material (records, or a
|
|
54
|
+
* counted crowd too large to enumerate). The term is answered.
|
|
55
55
|
* searched-empty — every slice landed and every one is a COUNTED zero: state `enumerated`, a finite
|
|
56
56
|
* total_hits of 0, an empty records array. The executed query and its empty result
|
|
57
57
|
* are both recorded on the row. This is the state that used to leave no trace.
|
|
58
|
+
* withheld-by-judgment — a slice repeated a waiting family the reading turn withheld, so it was never
|
|
59
|
+
* asked, and every other slice landed with nothing material. Nothing was found and
|
|
60
|
+
* nothing is claimed clean: the term rests on the reading turn's recorded judgment,
|
|
61
|
+
* named on the slice it settles (`withheld_as`).
|
|
58
62
|
* not-dispatched — no slice was ever minted for this term, with the reason the mint or the remedy
|
|
59
63
|
* derivation gave. The term was NOT searched, and now says so.
|
|
60
64
|
* dispatch-failed— a slice was minted but did not land as a usable executed block: unlanded, error,
|
|
@@ -64,7 +68,7 @@ export const REMEDY_ACCOUNTING_SCHEMA_VERSION = 1;
|
|
|
64
68
|
* counted zero (an uncountable total, an unrecognised state), or the receipt offers
|
|
65
69
|
* no mapping at all. NEVER guessed in either direction.
|
|
66
70
|
*/
|
|
67
|
-
export const REMEDY_TERM_CLASSES = ["found", "searched-empty", "not-dispatched", "dispatch-failed", "unaccounted"];
|
|
71
|
+
export const REMEDY_TERM_CLASSES = ["found", "searched-empty", "withheld-by-judgment", "not-dispatched", "dispatch-failed", "unaccounted"];
|
|
68
72
|
|
|
69
73
|
/**
|
|
70
74
|
* The two classes that mean "this term's search is ON THE RECORD". Deliberately NOT every class with a
|
|
@@ -73,6 +77,15 @@ export const REMEDY_TERM_CLASSES = ["found", "searched-empty", "not-dispatched",
|
|
|
73
77
|
*/
|
|
74
78
|
export const ACCOUNTED_CLASSES = ["found", "searched-empty"];
|
|
75
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The classes that leave nothing open: a search on the record, or the reading turn's recorded judgment not
|
|
82
|
+
* to ask the question at all. A withheld family is a wider question the ruled method lets that turn leave
|
|
83
|
+
* unasked, on the record with its reason, so a term resting on one is neither a hole nor a doubt. It is
|
|
84
|
+
* still not a search, which is why ACCOUNTED_CLASSES stays as it is. The not-accounted list, the doubts and
|
|
85
|
+
* the closure read below all read this one.
|
|
86
|
+
*/
|
|
87
|
+
export const SETTLED_CLASSES = [...ACCOUNTED_CLASSES, "withheld-by-judgment"];
|
|
88
|
+
|
|
76
89
|
const clip = (s, n = 240) => {
|
|
77
90
|
const t = String(s ?? "").replace(/\s+/g, " ").trim();
|
|
78
91
|
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
@@ -138,10 +151,13 @@ function material(block) {
|
|
|
138
151
|
* 2. ANY slice that did not land clean ⇒ `dispatch-failed`, naming the first such qid. Every slice
|
|
139
152
|
* must land, exactly as verifyRegisterDirectiveClose requires of a directive — one convention. A
|
|
140
153
|
* slice the FOLD refused as already-asked is read through the twin it duplicates first, because that
|
|
141
|
-
* slice cannot land and the twin is where its question was answered
|
|
154
|
+
* slice cannot land and the twin is where its question was answered. A slice refused as a family
|
|
155
|
+
* the reading turn withheld is settled by that judgment (`withheldAs`) and is left out of rules 2-5:
|
|
156
|
+
* it never ran, so it has no result to read either way;
|
|
142
157
|
* 3. all landed, ANY slice material ⇒ `found`;
|
|
143
|
-
* 4. all landed,
|
|
144
|
-
* 5.
|
|
158
|
+
* 4. all landed with nothing material, and a slice withheld ⇒ `withheld-by-judgment`;
|
|
159
|
+
* 5. all landed, EVERY slice a counted zero ⇒ `searched-empty`;
|
|
160
|
+
* 6. otherwise ⇒ `unaccounted`, with the shape that defeated the join stated.
|
|
145
161
|
* PURE.
|
|
146
162
|
*/
|
|
147
163
|
export function classifyRemedyTerm(row, { blocksByQid = new Map(), executedQids = new Set(), foldRefusals = new Map() } = {}) {
|
|
@@ -159,20 +175,31 @@ export function classifyRemedyTerm(row, { blocksByQid = new Map(), executedQids
|
|
|
159
175
|
// ONLY THE DUPLICATE KIND RESOLVES. An identity collision or a malformed term is a genuine fault and
|
|
160
176
|
// stays `dispatch-failed`: nothing else asked those questions, so nothing else answers them.
|
|
161
177
|
const landsAs = new Map(qids.map((q) => [q, answeredAs(q, foldRefusals)])); // one rule with the directive check
|
|
178
|
+
// — A SLICE REFUSED AS A FAMILY THE READING TURN WITHHELD IS SETTLED, NOT READ.
|
|
179
|
+
//
|
|
180
|
+
// Its twin never ran: the reading turn decided not to ask that family and recorded why, and the re-sweep
|
|
181
|
+
// refused to ask it on the turn's behalf. So there is no result to read, and reading the slice as one that
|
|
182
|
+
// failed to land would record a dispatch failure, and mint a doubt, over a decision the run had made on
|
|
183
|
+
// purpose. It is settled by the same rule the directive check uses (`withheldAs`), it keeps its own row
|
|
184
|
+
// saying which family it repeats, and it carries no count, no state and no executed flag of its own.
|
|
185
|
+
const settledBy = new Map(qids.map((q) => [q, withheldAs(q, foldRefusals)]).filter(([, w]) => w));
|
|
162
186
|
const blockFor = (q) => blocksByQid.get(landsAs.get(q) ?? q);
|
|
163
187
|
const ranAs = (q) => executedQids.has(landsAs.get(q) ?? q);
|
|
164
188
|
const slices = qids.map((q) => {
|
|
189
|
+
const settled = settledBy.get(q);
|
|
190
|
+
if (settled) return { ...sliceRow(q, null, false), refused_at_fold: "withheld-by-judgment", withheld_as: settled.family };
|
|
165
191
|
const via = landsAs.get(q);
|
|
166
192
|
const slice = sliceRow(q, blockFor(q), ranAs(q));
|
|
167
193
|
return via && via !== q ? { ...slice, answered_by: via, refused_at_fold: "duplicate-question" } : slice;
|
|
168
194
|
});
|
|
195
|
+
const asked = qids.filter((q) => !settledBy.has(q));
|
|
169
196
|
if (!qids.length) {
|
|
170
197
|
const reason = String(row?.dispatch_reason ?? "").trim();
|
|
171
198
|
return reason
|
|
172
199
|
? { class: "not-dispatched", basis: "no-slice-minted", reason: clip(reason, 300), slices }
|
|
173
200
|
: { class: "unaccounted", basis: "no-mapping", reason: "the receipt records no slice and no reason for this term — the join cannot say whether it was searched", slices };
|
|
174
201
|
}
|
|
175
|
-
for (const q of
|
|
202
|
+
for (const q of asked) {
|
|
176
203
|
const b = blockFor(q);
|
|
177
204
|
if (sliceLanded(b, ranAs(q))) continue;
|
|
178
205
|
const why = !ranAs(q) ? "slice-not-landed"
|
|
@@ -182,8 +209,11 @@ export function classifyRemedyTerm(row, { blocksByQid = new Map(), executedQids
|
|
|
182
209
|
: "collapsed-slice";
|
|
183
210
|
return { class: "dispatch-failed", basis: why, reason: `${why}:${q}${b?.reason ? ` — ${clip(b.reason, 200)}` : ""}`, slices };
|
|
184
211
|
}
|
|
185
|
-
const blocks =
|
|
212
|
+
const blocks = asked.map((q) => blockFor(q));
|
|
186
213
|
if (blocks.some(material)) return { class: "found", basis: "band-block", reason: null, slices };
|
|
214
|
+
// Nothing material came back, and part of the term was never asked by the turn's decision: a counted zero
|
|
215
|
+
// beside a question nobody asked is not a clean result for the term, so it is not `searched-empty`.
|
|
216
|
+
if (settledBy.size && blocks.every(countedZero)) return { class: "withheld-by-judgment", basis: "reading-turn-record", reason: null, slices };
|
|
187
217
|
if (blocks.every(countedZero)) return { class: "searched-empty", basis: "counted-zero", reason: null, slices };
|
|
188
218
|
const odd = blocks.find((b) => !countedZero(b));
|
|
189
219
|
return {
|
|
@@ -240,9 +270,10 @@ export function accountRemedyTerms({ terms = [], blocksByQid = new Map(), execut
|
|
|
240
270
|
totals,
|
|
241
271
|
by_directive: byDirective,
|
|
242
272
|
// the two lists a reader is owed, kept APART on purpose (see the zero-semantics note in the header):
|
|
243
|
-
// a term that ran and found nothing is EVIDENCE; a term that never ran is a hole.
|
|
273
|
+
// a term that ran and found nothing is EVIDENCE; a term that never ran is a hole. A term the reading
|
|
274
|
+
// turn's judgment settled is neither, and is in neither list: its row says which family it rests on.
|
|
244
275
|
searched_empty: rows.filter((r) => r.class === "searched-empty"),
|
|
245
|
-
not_accounted: rows.filter((r) => !
|
|
276
|
+
not_accounted: rows.filter((r) => !SETTLED_CLASSES.includes(r.class)),
|
|
246
277
|
out_of_scope: (Array.isArray(outOfScope) ? outOfScope : []).map((o) => ({
|
|
247
278
|
directive: String(o?.directive ?? ""), layer: String(o?.layer ?? ""),
|
|
248
279
|
reason: clip(o?.reason ?? "source-channel directive — swept by a prose byte-diff on the common-law findings, so it has no term unit and no per-query record to account", 300),
|
|
@@ -269,6 +300,11 @@ export function accountRemedyTerms({ terms = [], blocksByQid = new Map(), execut
|
|
|
269
300
|
* `accounting` may be null (the ledger did not build) — then this returns the caller's own prior answer
|
|
270
301
|
* untouched, because a join that could not run is not evidence of a gap. PURE.
|
|
271
302
|
*
|
|
303
|
+
* A term settled by the reading turn's recorded judgment does not hold the gap open (SETTLED_CLASSES): the
|
|
304
|
+
* re-sweep did not ask a question that turn had decided against, and holding a CLEAR on that would turn a
|
|
305
|
+
* recorded decision into an unfinished search. It closes nothing by itself either: the directive must still
|
|
306
|
+
* be verified-closed, and every slice that did run must have landed.
|
|
307
|
+
*
|
|
272
308
|
* @returns {{domClosed: boolean, blockedBy: {term, directive, class, reason}[]}}
|
|
273
309
|
*/
|
|
274
310
|
export function domClosedOverAccounted({ directives = [], swept = [], accounting = null, reopenKeyOf = reopenKey } = {}) {
|
|
@@ -283,7 +319,7 @@ export function domClosedOverAccounted({ directives = [], swept = [], accounting
|
|
|
283
319
|
if (!closedOnADominant) return { domClosed: false, blockedBy: [] };
|
|
284
320
|
if (!accounting || accounting.computable !== true) return { domClosed: true, blockedBy: [] };
|
|
285
321
|
const blockedBy = (accounting.rows ?? [])
|
|
286
|
-
.filter((r) => domKeys.has(r.directive) && !
|
|
322
|
+
.filter((r) => domKeys.has(r.directive) && !SETTLED_CLASSES.includes(r.class))
|
|
287
323
|
.map((r) => ({ term: r.term, directive: r.directive, class: r.class, reason: r.reason }));
|
|
288
324
|
return { domClosed: blockedBy.length === 0, blockedBy };
|
|
289
325
|
}
|
|
@@ -292,7 +328,7 @@ export function domClosedOverAccounted({ directives = [], swept = [], accounting
|
|
|
292
328
|
// AD-4 house rule: every field is written on EVERY row, so "no term went unaccounted"
|
|
293
329
|
// (unaccounted:0) and "the ledger could not run" (unaccounted:null) differ by VALUE, never by field
|
|
294
330
|
// presence.
|
|
295
|
-
export const REMEDY_ACCOUNTING_EVENT_FIELDS = ["terms", "found", "searched_empty", "not_dispatched",
|
|
331
|
+
export const REMEDY_ACCOUNTING_EVENT_FIELDS = ["terms", "found", "searched_empty", "withheld_by_judgment", "not_dispatched",
|
|
296
332
|
"dispatch_failed", "unaccounted"];
|
|
297
333
|
|
|
298
334
|
export function remedyAccountingEvent({ trigger = null, artifact = null, reason = null } = {}) {
|
|
@@ -313,6 +349,8 @@ export function remedyAccountingEvent({ trigger = null, artifact = null, reason
|
|
|
313
349
|
* doubt:remedy-accounting:unaccounted:N — the join cannot say either way
|
|
314
350
|
* A `searched-empty` term mints NOTHING: it is answered, and its query and empty result ride the
|
|
315
351
|
* artifact and the receipt. Minting a doubt for it would turn a completed search into an open question.
|
|
352
|
+
* A `withheld-by-judgment` term mints nothing either: it rests on the reading turn's recorded decision not
|
|
353
|
+
* to ask, and a doubt would re-open that decision as a question nobody had answered.
|
|
316
354
|
*
|
|
317
355
|
* The frozen doubt-record shape, status "open"; stitchDoubts and the doubt-closure stage decide endings
|
|
318
356
|
* exactly as for every other doubt family, and an OPEN one shipping is the system working. Never gates,
|