@hraness/kb 0.18.0 → 0.19.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 +197 -113
- package/dist/authoring.js +2 -2
- package/dist/benchmark.js +3 -3
- package/dist/cli.js +16 -12
- package/dist/evaluation-builder.js +5 -5
- package/dist/evaluation-kb.js +5 -5
- package/dist/graph.js +3 -1
- package/dist/{index-zxdy5pby.js → index-5m2ydj5q.js} +2 -2
- package/dist/{index-cxfrakt7.js → index-ekpwvbra.js} +5 -2
- package/dist/{index-jsmvyyvf.js → index-ey46z1zf.js} +4 -4
- package/dist/{index-cv6fh7z5.js → index-gm9t95d9.js} +1 -1
- package/dist/{index-01jj6rbv.js → index-gxr0fctd.js} +3 -3
- package/dist/index-nd6nynv2.js +1162 -0
- package/dist/{index-s2gw5aw9.js → index-qwgsmtsz.js} +1 -1
- package/dist/{index-zzhgcwyt.js → index-vxmf14m1.js} +3 -3
- package/dist/{index-n5dd7r0v.js → index-xw9ac71d.js} +2 -2
- package/dist/{index-1vrd1rmn.js → index-ykvvkd77.js} +1 -1
- package/dist/index.js +30 -8
- package/dist/percolate.js +22 -2
- package/dist/portfolio.js +5 -5
- package/dist/sdk.js +4 -4
- package/dist/search.js +2 -2
- package/dist/semantic.js +3 -3
- package/dist/workflows/decision-context.js +5 -5
- package/dist/workflows/index.js +5 -5
- package/package.json +1 -1
- package/skills/kb/AGENTS.md +3 -0
- package/skills/kb/SKILL.md +38 -29
- package/skills/kb/agents/openai.yaml +2 -2
- package/skills/kb/references/companion-skills.md +96 -0
- package/skills/kb/references/customize.md +123 -0
- package/skills/kb/references/percolate.md +39 -7
- package/skills/kb/references/query.md +21 -0
- package/skills/kb/templates/companion-skill.template.md +57 -0
- package/src/authoring.ts +5 -3
- package/src/cli.ts +12 -7
- package/src/graph.ts +8 -1
- package/src/percolate.ts +1088 -17
- package/dist/index-dyqwejk5.js +0 -531
package/src/percolate.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { posix } from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
isCanonicalNoteId,
|
|
6
|
+
isCanonicalRelationPredicate,
|
|
5
7
|
lookupNote,
|
|
6
8
|
MAX_ANALYZED_NOTES,
|
|
7
9
|
MAX_MENTIONS,
|
|
@@ -18,6 +20,10 @@ export const MAX_PERCOLATION_MENTION_PAIRS = 250_000;
|
|
|
18
20
|
export const MAX_PERCOLATION_MENTIONS = MAX_MENTIONS;
|
|
19
21
|
export const MAX_SCOPED_PERCOLATION_MENTION_PAIRS =
|
|
20
22
|
MAX_PERCOLATION_NOTES * 2;
|
|
23
|
+
export const PERCOLATION_RESULT_SCHEMA_VERSION = 2 as const;
|
|
24
|
+
export const MAX_PERCOLATION_RESULT_NODES = 250_000;
|
|
25
|
+
export const MAX_PERCOLATION_RESULT_UTF8_BYTES = 16 * 1024 * 1024;
|
|
26
|
+
export const MAX_PERCOLATION_TEXT_UTF8_BYTES = 64 * 1024;
|
|
21
27
|
|
|
22
28
|
const MAX_PERCOLATION_EVIDENCE = 250_000;
|
|
23
29
|
const MAX_PERCOLATION_PAIR_OBSERVATIONS = MAX_PERCOLATION_MENTION_PAIRS;
|
|
@@ -84,7 +90,10 @@ export type MissingConceptCandidate = {
|
|
|
84
90
|
readonly evidence: readonly MissingConceptEvidence[];
|
|
85
91
|
};
|
|
86
92
|
|
|
87
|
-
export type
|
|
93
|
+
export type PredicateDisposition = { readonly kind: "required" };
|
|
94
|
+
|
|
95
|
+
/** @deprecated Archival V1 shape; retained through the 0.19.x compatibility cycle. */
|
|
96
|
+
export type MissingRelationCandidateV1 = {
|
|
88
97
|
readonly kind: "missing-relation";
|
|
89
98
|
readonly source: string;
|
|
90
99
|
readonly target: string;
|
|
@@ -94,6 +103,20 @@ export type MissingRelationCandidate = {
|
|
|
94
103
|
readonly evidence: readonly (SharedTagEvidence | SharedConceptEvidence)[];
|
|
95
104
|
};
|
|
96
105
|
|
|
106
|
+
export type MissingRelationCandidateV2 = {
|
|
107
|
+
readonly kind: "missing-relation";
|
|
108
|
+
/** Lexicographically ordered endpoint; this is not semantic direction. */
|
|
109
|
+
readonly source: string;
|
|
110
|
+
/** Lexicographically ordered endpoint; this is not semantic direction. */
|
|
111
|
+
readonly target: string;
|
|
112
|
+
readonly predicate: PredicateDisposition;
|
|
113
|
+
readonly support: number;
|
|
114
|
+
readonly evidenceTruncated: boolean;
|
|
115
|
+
readonly evidence: readonly (SharedTagEvidence | SharedConceptEvidence)[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type MissingRelationCandidate = MissingRelationCandidateV2;
|
|
119
|
+
|
|
97
120
|
export type UnlinkedMentionCandidate = {
|
|
98
121
|
readonly kind: "unlinked-mention";
|
|
99
122
|
readonly source: string;
|
|
@@ -122,12 +145,20 @@ export type RelationHygieneCandidate = {
|
|
|
122
145
|
readonly evidence: readonly (RelationEvidence | RelationIssueEvidence)[];
|
|
123
146
|
};
|
|
124
147
|
|
|
125
|
-
export type
|
|
148
|
+
export type PercolationCandidateV1 =
|
|
126
149
|
| MissingConceptCandidate
|
|
127
|
-
|
|
|
150
|
+
| MissingRelationCandidateV1
|
|
128
151
|
| UnlinkedMentionCandidate
|
|
129
152
|
| RelationHygieneCandidate;
|
|
130
153
|
|
|
154
|
+
export type PercolationCandidateV2 =
|
|
155
|
+
| MissingConceptCandidate
|
|
156
|
+
| MissingRelationCandidateV2
|
|
157
|
+
| UnlinkedMentionCandidate
|
|
158
|
+
| RelationHygieneCandidate;
|
|
159
|
+
|
|
160
|
+
export type PercolationCandidate = PercolationCandidateV2;
|
|
161
|
+
|
|
131
162
|
export type PercolateOptions = {
|
|
132
163
|
/** Limit candidates to evidence involving this resolvable note. */
|
|
133
164
|
readonly note?: string;
|
|
@@ -136,8 +167,43 @@ export type PercolateOptions = {
|
|
|
136
167
|
readonly limit?: number;
|
|
137
168
|
};
|
|
138
169
|
|
|
139
|
-
|
|
140
|
-
|
|
170
|
+
/**
|
|
171
|
+
* @deprecated Historical unversioned result retained for explicit archival
|
|
172
|
+
* parsing through 0.19.x; it may be removed no earlier than 0.20.0.
|
|
173
|
+
*/
|
|
174
|
+
export type PercolationResultV1 = {
|
|
175
|
+
readonly candidates: readonly PercolationCandidateV1[];
|
|
176
|
+
readonly truncated: boolean;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export type PercolationResultV2 = {
|
|
180
|
+
readonly schemaVersion: typeof PERCOLATION_RESULT_SCHEMA_VERSION;
|
|
181
|
+
readonly candidates: readonly PercolationCandidateV2[];
|
|
182
|
+
readonly truncated: boolean;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export type PercolationResult = PercolationResultV2;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @deprecated Historical JSON envelope emitted by `kb percolate --json`,
|
|
189
|
+
* retained through 0.19.x and removable no earlier than 0.20.0.
|
|
190
|
+
*/
|
|
191
|
+
export type PercolationCliOutputV1 = {
|
|
192
|
+
readonly root: string;
|
|
193
|
+
readonly note: string | null;
|
|
194
|
+
readonly minSupport: number;
|
|
195
|
+
readonly candidates: readonly PercolationCandidateV1[];
|
|
196
|
+
readonly truncated: boolean;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export type PercolationCliOutputV2 = {
|
|
200
|
+
readonly root: string;
|
|
201
|
+
/** The caller's free-form lookup text, not a canonical note identity. */
|
|
202
|
+
readonly note: string | null;
|
|
203
|
+
readonly minSupport: number;
|
|
204
|
+
readonly limit: number;
|
|
205
|
+
readonly schemaVersion: typeof PERCOLATION_RESULT_SCHEMA_VERSION;
|
|
206
|
+
readonly candidates: readonly PercolationCandidateV2[];
|
|
141
207
|
readonly truncated: boolean;
|
|
142
208
|
};
|
|
143
209
|
|
|
@@ -184,6 +250,7 @@ type AnalysisWithRelations = VaultAnalysis & {
|
|
|
184
250
|
};
|
|
185
251
|
|
|
186
252
|
type SharedEvidence = SharedTagEvidence | SharedConceptEvidence;
|
|
253
|
+
type AnyPercolationCandidate = PercolationCandidateV1 | PercolationCandidateV2;
|
|
187
254
|
|
|
188
255
|
type SharedAccumulation = {
|
|
189
256
|
support: number;
|
|
@@ -337,19 +404,35 @@ function naturalConceptId(tag: string): string {
|
|
|
337
404
|
function suggestedConceptId(
|
|
338
405
|
tag: string,
|
|
339
406
|
occupiedIds: ReadonlyMap<string, string>,
|
|
407
|
+
reservedIds: Set<string>,
|
|
408
|
+
nextSuffixByNaturalId: Map<string, number>,
|
|
340
409
|
): { readonly id: string; readonly collidesWith: string | null } {
|
|
341
410
|
const natural = naturalConceptId(tag);
|
|
342
411
|
const foldedNatural = natural.toLocaleLowerCase("en-US");
|
|
343
412
|
const collidesWith = occupiedIds.get(foldedNatural) ?? null;
|
|
344
|
-
if (collidesWith === null)
|
|
413
|
+
if (collidesWith === null && !reservedIds.has(foldedNatural)) {
|
|
414
|
+
reservedIds.add(foldedNatural);
|
|
415
|
+
return { id: natural, collidesWith: null };
|
|
416
|
+
}
|
|
345
417
|
|
|
346
418
|
const suffixed = `${natural}-concept`;
|
|
347
|
-
|
|
419
|
+
const foldedSuffixed = suffixed.toLocaleLowerCase("en-US");
|
|
420
|
+
if (!occupiedIds.has(foldedSuffixed) && !reservedIds.has(foldedSuffixed)) {
|
|
421
|
+
reservedIds.add(foldedSuffixed);
|
|
422
|
+
nextSuffixByNaturalId.set(foldedNatural, 2);
|
|
348
423
|
return { id: suffixed, collidesWith };
|
|
349
424
|
}
|
|
350
|
-
|
|
425
|
+
const nextSuffix = nextSuffixByNaturalId.get(foldedNatural) ?? 2;
|
|
426
|
+
for (
|
|
427
|
+
let suffix = nextSuffix;
|
|
428
|
+
suffix <= MAX_PERCOLATION_EVIDENCE + 2;
|
|
429
|
+
suffix += 1
|
|
430
|
+
) {
|
|
351
431
|
const candidate = `${suffixed}-${suffix}`;
|
|
352
|
-
|
|
432
|
+
const foldedCandidate = candidate.toLocaleLowerCase("en-US");
|
|
433
|
+
if (!occupiedIds.has(foldedCandidate) && !reservedIds.has(foldedCandidate)) {
|
|
434
|
+
reservedIds.add(foldedCandidate);
|
|
435
|
+
nextSuffixByNaturalId.set(foldedNatural, suffix + 1);
|
|
353
436
|
return { id: candidate, collidesWith };
|
|
354
437
|
}
|
|
355
438
|
}
|
|
@@ -392,12 +475,31 @@ function compareSharedEvidence(
|
|
|
392
475
|
|| compareText(left.note, right.note);
|
|
393
476
|
}
|
|
394
477
|
|
|
395
|
-
function candidateIdentity(candidate:
|
|
478
|
+
function candidateIdentity(candidate: AnyPercolationCandidate): string {
|
|
396
479
|
switch (candidate.kind) {
|
|
397
480
|
case "missing-concept":
|
|
398
481
|
return candidate.tag;
|
|
399
482
|
case "missing-relation":
|
|
400
483
|
return `${candidate.source}\u0000${candidate.target}`;
|
|
484
|
+
case "unlinked-mention":
|
|
485
|
+
return `${candidate.source}\u0000${candidate.target}`;
|
|
486
|
+
case "relation-hygiene":
|
|
487
|
+
return [
|
|
488
|
+
candidate.problem,
|
|
489
|
+
candidate.source,
|
|
490
|
+
candidate.predicate ?? "",
|
|
491
|
+
candidate.target ?? "",
|
|
492
|
+
candidate.message,
|
|
493
|
+
String(candidate.evidence[0]?.line ?? 0),
|
|
494
|
+
].join("\u0000");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function historicalCandidateIdentity(candidate: PercolationCandidateV1): string {
|
|
499
|
+
switch (candidate.kind) {
|
|
500
|
+
case "missing-concept":
|
|
501
|
+
return candidate.tag;
|
|
502
|
+
case "missing-relation":
|
|
401
503
|
case "unlinked-mention":
|
|
402
504
|
return `${candidate.source}\u0000${candidate.target}`;
|
|
403
505
|
case "relation-hygiene":
|
|
@@ -410,7 +512,7 @@ function candidateIdentity(candidate: PercolationCandidate): string {
|
|
|
410
512
|
}
|
|
411
513
|
}
|
|
412
514
|
|
|
413
|
-
const candidateKindRank: Readonly<Record<
|
|
515
|
+
const candidateKindRank: Readonly<Record<AnyPercolationCandidate["kind"], number>> = {
|
|
414
516
|
"relation-hygiene": 0,
|
|
415
517
|
"unlinked-mention": 1,
|
|
416
518
|
"missing-relation": 2,
|
|
@@ -418,14 +520,26 @@ const candidateKindRank: Readonly<Record<PercolationCandidate["kind"], number>>
|
|
|
418
520
|
};
|
|
419
521
|
|
|
420
522
|
function compareCandidates(
|
|
421
|
-
left:
|
|
422
|
-
right:
|
|
523
|
+
left: AnyPercolationCandidate,
|
|
524
|
+
right: AnyPercolationCandidate,
|
|
423
525
|
): number {
|
|
424
526
|
return right.support - left.support
|
|
425
527
|
|| candidateKindRank[left.kind] - candidateKindRank[right.kind]
|
|
426
528
|
|| compareText(candidateIdentity(left), candidateIdentity(right));
|
|
427
529
|
}
|
|
428
530
|
|
|
531
|
+
function compareHistoricalCandidates(
|
|
532
|
+
left: PercolationCandidateV1,
|
|
533
|
+
right: PercolationCandidateV1,
|
|
534
|
+
): number {
|
|
535
|
+
return right.support - left.support
|
|
536
|
+
|| candidateKindRank[left.kind] - candidateKindRank[right.kind]
|
|
537
|
+
|| compareText(
|
|
538
|
+
historicalCandidateIdentity(left),
|
|
539
|
+
historicalCandidateIdentity(right),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
429
543
|
function relationEvidence(relation: AuthoredRelationLike): RelationEvidence {
|
|
430
544
|
return {
|
|
431
545
|
kind: "relation",
|
|
@@ -523,6 +637,8 @@ export function percolateVault(
|
|
|
523
637
|
note.id.toLocaleLowerCase("en-US"),
|
|
524
638
|
note.id,
|
|
525
639
|
]));
|
|
640
|
+
const reservedConceptIds = new Set<string>();
|
|
641
|
+
const nextConceptSuffixByNaturalId = new Map<string, number>();
|
|
526
642
|
const nonConceptNotes = indexed.notes.filter((note) => !conceptIds.has(note.id));
|
|
527
643
|
const conceptLabelKeys = new Set(
|
|
528
644
|
indexed.notes
|
|
@@ -566,7 +682,12 @@ export function percolateVault(
|
|
|
566
682
|
&& !conceptLabelKeys.has(conceptKey(tag))
|
|
567
683
|
&& (noteFilter === null || matchingNotes.some((note) => note.id === noteFilter))
|
|
568
684
|
) {
|
|
569
|
-
const suggestion = suggestedConceptId(
|
|
685
|
+
const suggestion = suggestedConceptId(
|
|
686
|
+
tag,
|
|
687
|
+
occupiedIds,
|
|
688
|
+
reservedConceptIds,
|
|
689
|
+
nextConceptSuffixByNaturalId,
|
|
690
|
+
);
|
|
570
691
|
candidates.push({
|
|
571
692
|
kind: "missing-concept",
|
|
572
693
|
tag,
|
|
@@ -728,7 +849,7 @@ export function percolateVault(
|
|
|
728
849
|
kind: "missing-relation",
|
|
729
850
|
source,
|
|
730
851
|
target,
|
|
731
|
-
|
|
852
|
+
predicate: { kind: "required" },
|
|
732
853
|
support: accumulated.support,
|
|
733
854
|
evidenceTruncated:
|
|
734
855
|
accumulated.evidenceCount > MAX_PERCOLATION_EVIDENCE_PER_CANDIDATE,
|
|
@@ -867,11 +988,961 @@ export function percolateVault(
|
|
|
867
988
|
`Percolation exceeds the ${MAX_PERCOLATION_EVIDENCE} candidate limit.`,
|
|
868
989
|
);
|
|
869
990
|
}
|
|
870
|
-
const
|
|
991
|
+
const uniqueCandidates = new Map<string, PercolationCandidate>();
|
|
992
|
+
for (const candidate of candidates) {
|
|
993
|
+
const identity = `${candidate.kind}\u0000${candidateIdentity(candidate)}`;
|
|
994
|
+
if (!uniqueCandidates.has(identity)) uniqueCandidates.set(identity, candidate);
|
|
995
|
+
}
|
|
996
|
+
const sorted = [...uniqueCandidates.values()]
|
|
871
997
|
.filter((candidate) => candidateInvolvesNote(candidate, noteFilter))
|
|
872
998
|
.toSorted(compareCandidates);
|
|
873
|
-
return {
|
|
999
|
+
return parsePercolationResultV2({
|
|
1000
|
+
schemaVersion: PERCOLATION_RESULT_SCHEMA_VERSION,
|
|
874
1001
|
candidates: sorted.slice(0, limit),
|
|
875
1002
|
truncated: sorted.length > limit,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
type ParseBudget = {
|
|
1007
|
+
nodes: number;
|
|
1008
|
+
utf8Bytes: number;
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
type DataRecord = Readonly<Record<string, unknown>>;
|
|
1012
|
+
|
|
1013
|
+
function countParseNode(budget: ParseBudget, label: string): void {
|
|
1014
|
+
budget.nodes += 1;
|
|
1015
|
+
if (budget.nodes > MAX_PERCOLATION_RESULT_NODES) {
|
|
1016
|
+
throw new RangeError(
|
|
1017
|
+
`${label} exceeds the ${MAX_PERCOLATION_RESULT_NODES.toLocaleString("en-US")}-node percolation result limit.`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function dataRecord(
|
|
1023
|
+
value: unknown,
|
|
1024
|
+
label: string,
|
|
1025
|
+
budget: ParseBudget,
|
|
1026
|
+
): DataRecord {
|
|
1027
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1028
|
+
throw new TypeError(`${label} must be a plain data object.`);
|
|
1029
|
+
}
|
|
1030
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
1031
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1032
|
+
throw new TypeError(`${label} must be a plain data object.`);
|
|
1033
|
+
}
|
|
1034
|
+
countParseNode(budget, label);
|
|
1035
|
+
const output = Object.create(null) as Record<string, unknown>;
|
|
1036
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1037
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
1038
|
+
if (typeof key !== "string") {
|
|
1039
|
+
throw new TypeError(`${label} must not contain symbol fields.`);
|
|
1040
|
+
}
|
|
1041
|
+
const descriptor = descriptors[key];
|
|
1042
|
+
if (
|
|
1043
|
+
descriptor === undefined
|
|
1044
|
+
|| !("value" in descriptor)
|
|
1045
|
+
|| !descriptor.enumerable
|
|
1046
|
+
) {
|
|
1047
|
+
throw new TypeError(`${label}.${key} must be an enumerable data property.`);
|
|
1048
|
+
}
|
|
1049
|
+
Object.defineProperty(output, key, {
|
|
1050
|
+
configurable: false,
|
|
1051
|
+
enumerable: true,
|
|
1052
|
+
value: descriptor.value,
|
|
1053
|
+
writable: false,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
return Object.freeze(output);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function exactKeys(
|
|
1060
|
+
record: DataRecord,
|
|
1061
|
+
keys: readonly string[],
|
|
1062
|
+
label: string,
|
|
1063
|
+
): void {
|
|
1064
|
+
const actual = Reflect.ownKeys(record);
|
|
1065
|
+
const expected = new Set(keys);
|
|
1066
|
+
if (
|
|
1067
|
+
actual.length !== keys.length
|
|
1068
|
+
|| actual.some((key) => typeof key !== "string" || !expected.has(key))
|
|
1069
|
+
) {
|
|
1070
|
+
throw new TypeError(`${label} must contain exactly: ${keys.join(", ")}.`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function dataArray(
|
|
1075
|
+
value: unknown,
|
|
1076
|
+
label: string,
|
|
1077
|
+
maximum: number,
|
|
1078
|
+
budget: ParseBudget,
|
|
1079
|
+
): readonly unknown[] {
|
|
1080
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) {
|
|
1081
|
+
throw new TypeError(`${label} must be an ordinary array.`);
|
|
1082
|
+
}
|
|
1083
|
+
if (value.length > maximum) {
|
|
1084
|
+
throw new RangeError(
|
|
1085
|
+
`${label} exceeds its ${maximum.toLocaleString("en-US")}-entry limit.`,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
countParseNode(budget, label);
|
|
1089
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
1090
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
1091
|
+
if (typeof key !== "string") {
|
|
1092
|
+
throw new TypeError(`${label} must not contain symbol fields.`);
|
|
1093
|
+
}
|
|
1094
|
+
if (key === "length") continue;
|
|
1095
|
+
const index = Number(key);
|
|
1096
|
+
if (
|
|
1097
|
+
!Number.isSafeInteger(index)
|
|
1098
|
+
|| index < 0
|
|
1099
|
+
|| index >= value.length
|
|
1100
|
+
|| String(index) !== key
|
|
1101
|
+
) {
|
|
1102
|
+
throw new TypeError(`${label} contains a non-index property.`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const output: unknown[] = [];
|
|
1106
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1107
|
+
const descriptor = descriptors[String(index)];
|
|
1108
|
+
if (
|
|
1109
|
+
descriptor === undefined
|
|
1110
|
+
|| !("value" in descriptor)
|
|
1111
|
+
|| !descriptor.enumerable
|
|
1112
|
+
) {
|
|
1113
|
+
throw new TypeError(`${label} must be a dense array of data properties.`);
|
|
1114
|
+
}
|
|
1115
|
+
output.push(descriptor.value);
|
|
1116
|
+
}
|
|
1117
|
+
return Object.freeze(output);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function hasUnpairedSurrogate(value: string): boolean {
|
|
1121
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1122
|
+
const code = value.charCodeAt(index);
|
|
1123
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
1124
|
+
const next = value.charCodeAt(index + 1);
|
|
1125
|
+
if (next < 0xdc00 || next > 0xdfff) return true;
|
|
1126
|
+
index += 1;
|
|
1127
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function parsedText(
|
|
1135
|
+
value: unknown,
|
|
1136
|
+
label: string,
|
|
1137
|
+
budget: ParseBudget,
|
|
1138
|
+
options: { readonly empty?: boolean } = {},
|
|
1139
|
+
): string {
|
|
1140
|
+
if (
|
|
1141
|
+
typeof value !== "string"
|
|
1142
|
+
|| (options.empty !== true && value === "")
|
|
1143
|
+
|| hasUnpairedSurrogate(value)
|
|
1144
|
+
) {
|
|
1145
|
+
throw new TypeError(`${label} must be a bounded Unicode string.`);
|
|
1146
|
+
}
|
|
1147
|
+
const bytes = new TextEncoder().encode(value).byteLength;
|
|
1148
|
+
if (bytes > MAX_PERCOLATION_TEXT_UTF8_BYTES) {
|
|
1149
|
+
throw new RangeError(
|
|
1150
|
+
`${label} exceeds its ${MAX_PERCOLATION_TEXT_UTF8_BYTES.toLocaleString("en-US")}-byte limit.`,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
budget.utf8Bytes += bytes;
|
|
1154
|
+
if (budget.utf8Bytes > MAX_PERCOLATION_RESULT_UTF8_BYTES) {
|
|
1155
|
+
throw new RangeError(
|
|
1156
|
+
`Percolation result text exceeds ${MAX_PERCOLATION_RESULT_UTF8_BYTES.toLocaleString("en-US")} UTF-8 bytes.`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
return value;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function canonicalNote(
|
|
1163
|
+
value: unknown,
|
|
1164
|
+
label: string,
|
|
1165
|
+
budget: ParseBudget,
|
|
1166
|
+
): string {
|
|
1167
|
+
const parsed = parsedText(value, label, budget);
|
|
1168
|
+
if (!isCanonicalNoteId(parsed)) {
|
|
1169
|
+
throw new TypeError(`${label} must be a canonical note ID.`);
|
|
1170
|
+
}
|
|
1171
|
+
return parsed;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function canonicalMarkdownPath(
|
|
1175
|
+
value: unknown,
|
|
1176
|
+
label: string,
|
|
1177
|
+
budget: ParseBudget,
|
|
1178
|
+
): string {
|
|
1179
|
+
const parsed = parsedText(value, label, budget);
|
|
1180
|
+
if (
|
|
1181
|
+
!parsed.endsWith(".md")
|
|
1182
|
+
|| !isCanonicalNoteId(parsed.slice(0, -3))
|
|
1183
|
+
) {
|
|
1184
|
+
throw new TypeError(`${label} must be a canonical vault Markdown path.`);
|
|
1185
|
+
}
|
|
1186
|
+
return parsed;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function canonicalPredicate(
|
|
1190
|
+
value: unknown,
|
|
1191
|
+
label: string,
|
|
1192
|
+
budget: ParseBudget,
|
|
1193
|
+
): string {
|
|
1194
|
+
const parsed = parsedText(value, label, budget);
|
|
1195
|
+
if (!isCanonicalRelationPredicate(parsed)) {
|
|
1196
|
+
throw new TypeError(`${label} must be a canonical relation predicate.`);
|
|
1197
|
+
}
|
|
1198
|
+
return parsed;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function nullableText(
|
|
1202
|
+
value: unknown,
|
|
1203
|
+
label: string,
|
|
1204
|
+
budget: ParseBudget,
|
|
1205
|
+
): string | null {
|
|
1206
|
+
return value === null ? null : parsedText(value, label, budget, { empty: true });
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function parsedBoolean(value: unknown, label: string): boolean {
|
|
1210
|
+
if (typeof value !== "boolean") throw new TypeError(`${label} must be a boolean.`);
|
|
1211
|
+
return value;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function positiveSafeInteger(
|
|
1215
|
+
value: unknown,
|
|
1216
|
+
label: string,
|
|
1217
|
+
maximum = MAX_PERCOLATION_EVIDENCE,
|
|
1218
|
+
): number {
|
|
1219
|
+
if (
|
|
1220
|
+
typeof value !== "number"
|
|
1221
|
+
|| !Number.isSafeInteger(value)
|
|
1222
|
+
|| value < 1
|
|
1223
|
+
|| value > maximum
|
|
1224
|
+
) {
|
|
1225
|
+
throw new TypeError(`${label} must be a positive bounded safe integer.`);
|
|
1226
|
+
}
|
|
1227
|
+
return value;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function parsedMinSupport(value: unknown, label: string): number {
|
|
1231
|
+
if (
|
|
1232
|
+
typeof value !== "number"
|
|
1233
|
+
|| !Number.isSafeInteger(value)
|
|
1234
|
+
|| value < DEFAULT_PERCOLATION_MIN_SUPPORT
|
|
1235
|
+
|| value > MAX_PERCOLATION_LIMIT
|
|
1236
|
+
) {
|
|
1237
|
+
throw new TypeError(
|
|
1238
|
+
`${label} must be an integer from ${DEFAULT_PERCOLATION_MIN_SUPPORT} through ${MAX_PERCOLATION_LIMIT}.`,
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
return value;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function predicateDisposition(
|
|
1245
|
+
value: unknown,
|
|
1246
|
+
label: string,
|
|
1247
|
+
budget: ParseBudget,
|
|
1248
|
+
): PredicateDisposition {
|
|
1249
|
+
const record = dataRecord(value, label, budget);
|
|
1250
|
+
const kind = parsedText(record.kind, `${label}.kind`, budget);
|
|
1251
|
+
if (kind === "required") {
|
|
1252
|
+
exactKeys(record, ["kind"], label);
|
|
1253
|
+
return Object.freeze({ kind: "required" });
|
|
1254
|
+
}
|
|
1255
|
+
throw new TypeError(`${label}.kind must be required.`);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function parsedMissingConceptEvidence(
|
|
1259
|
+
value: unknown,
|
|
1260
|
+
label: string,
|
|
1261
|
+
budget: ParseBudget,
|
|
1262
|
+
): MissingConceptEvidence {
|
|
1263
|
+
const record = dataRecord(value, label, budget);
|
|
1264
|
+
exactKeys(record, ["kind", "note", "path", "tag"], label);
|
|
1265
|
+
if (parsedText(record.kind, `${label}.kind`, budget) !== "tag") {
|
|
1266
|
+
throw new TypeError(`${label}.kind must be tag.`);
|
|
1267
|
+
}
|
|
1268
|
+
const note = canonicalNote(record.note, `${label}.note`, budget);
|
|
1269
|
+
const path = canonicalMarkdownPath(record.path, `${label}.path`, budget);
|
|
1270
|
+
if (path !== `${note}.md`) throw new TypeError(`${label}.path must identify its note.`);
|
|
1271
|
+
return Object.freeze({
|
|
1272
|
+
kind: "tag",
|
|
1273
|
+
note,
|
|
1274
|
+
path,
|
|
1275
|
+
tag: parsedText(record.tag, `${label}.tag`, budget),
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function parsedSharedEvidence(
|
|
1280
|
+
value: unknown,
|
|
1281
|
+
label: string,
|
|
1282
|
+
budget: ParseBudget,
|
|
1283
|
+
): SharedEvidence {
|
|
1284
|
+
const record = dataRecord(value, label, budget);
|
|
1285
|
+
const kind = parsedText(record.kind, `${label}.kind`, budget);
|
|
1286
|
+
if (kind === "shared-tag") {
|
|
1287
|
+
exactKeys(record, ["kind", "note", "path", "tag"], label);
|
|
1288
|
+
const note = canonicalNote(record.note, `${label}.note`, budget);
|
|
1289
|
+
const path = canonicalMarkdownPath(record.path, `${label}.path`, budget);
|
|
1290
|
+
if (path !== `${note}.md`) throw new TypeError(`${label}.path must identify its note.`);
|
|
1291
|
+
return Object.freeze({
|
|
1292
|
+
kind: "shared-tag",
|
|
1293
|
+
note,
|
|
1294
|
+
path,
|
|
1295
|
+
tag: parsedText(record.tag, `${label}.tag`, budget),
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
if (kind === "shared-concept") {
|
|
1299
|
+
exactKeys(
|
|
1300
|
+
record,
|
|
1301
|
+
["kind", "note", "path", "concept", "conceptPath"],
|
|
1302
|
+
label,
|
|
1303
|
+
);
|
|
1304
|
+
const note = canonicalNote(record.note, `${label}.note`, budget);
|
|
1305
|
+
const path = canonicalMarkdownPath(record.path, `${label}.path`, budget);
|
|
1306
|
+
const concept = canonicalNote(record.concept, `${label}.concept`, budget);
|
|
1307
|
+
const conceptPath = canonicalMarkdownPath(
|
|
1308
|
+
record.conceptPath,
|
|
1309
|
+
`${label}.conceptPath`,
|
|
1310
|
+
budget,
|
|
1311
|
+
);
|
|
1312
|
+
if (path !== `${note}.md`) throw new TypeError(`${label}.path must identify its note.`);
|
|
1313
|
+
if (conceptPath !== `${concept}.md`) {
|
|
1314
|
+
throw new TypeError(`${label}.conceptPath must identify its concept.`);
|
|
1315
|
+
}
|
|
1316
|
+
return Object.freeze({
|
|
1317
|
+
kind: "shared-concept",
|
|
1318
|
+
note,
|
|
1319
|
+
path,
|
|
1320
|
+
concept,
|
|
1321
|
+
conceptPath,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
throw new TypeError(`${label}.kind must be shared-tag or shared-concept.`);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function parsedMentionEvidence(
|
|
1328
|
+
value: unknown,
|
|
1329
|
+
label: string,
|
|
1330
|
+
budget: ParseBudget,
|
|
1331
|
+
): MentionEvidence {
|
|
1332
|
+
const record = dataRecord(value, label, budget);
|
|
1333
|
+
exactKeys(record, ["kind", "source", "target", "line", "phrase"], label);
|
|
1334
|
+
if (parsedText(record.kind, `${label}.kind`, budget) !== "mention") {
|
|
1335
|
+
throw new TypeError(`${label}.kind must be mention.`);
|
|
1336
|
+
}
|
|
1337
|
+
return Object.freeze({
|
|
1338
|
+
kind: "mention",
|
|
1339
|
+
source: canonicalNote(record.source, `${label}.source`, budget),
|
|
1340
|
+
target: canonicalNote(record.target, `${label}.target`, budget),
|
|
1341
|
+
line: positiveSafeInteger(record.line, `${label}.line`),
|
|
1342
|
+
phrase: parsedText(record.phrase, `${label}.phrase`, budget),
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function parsedRelationEvidence(
|
|
1347
|
+
value: unknown,
|
|
1348
|
+
label: string,
|
|
1349
|
+
budget: ParseBudget,
|
|
1350
|
+
): RelationEvidence {
|
|
1351
|
+
const record = dataRecord(value, label, budget);
|
|
1352
|
+
exactKeys(
|
|
1353
|
+
record,
|
|
1354
|
+
["kind", "source", "target", "predicate", "line", "authoredTarget"],
|
|
1355
|
+
label,
|
|
1356
|
+
);
|
|
1357
|
+
if (parsedText(record.kind, `${label}.kind`, budget) !== "relation") {
|
|
1358
|
+
throw new TypeError(`${label}.kind must be relation.`);
|
|
1359
|
+
}
|
|
1360
|
+
return Object.freeze({
|
|
1361
|
+
kind: "relation",
|
|
1362
|
+
source: canonicalNote(record.source, `${label}.source`, budget),
|
|
1363
|
+
target: canonicalNote(record.target, `${label}.target`, budget),
|
|
1364
|
+
predicate: canonicalPredicate(record.predicate, `${label}.predicate`, budget),
|
|
1365
|
+
line: positiveSafeInteger(record.line, `${label}.line`),
|
|
1366
|
+
authoredTarget: parsedText(record.authoredTarget, `${label}.authoredTarget`, budget),
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function parsedRelationIssueEvidence(
|
|
1371
|
+
value: unknown,
|
|
1372
|
+
label: string,
|
|
1373
|
+
budget: ParseBudget,
|
|
1374
|
+
): RelationIssueEvidence {
|
|
1375
|
+
const record = dataRecord(value, label, budget);
|
|
1376
|
+
exactKeys(
|
|
1377
|
+
record,
|
|
1378
|
+
[
|
|
1379
|
+
"kind",
|
|
1380
|
+
"issue",
|
|
1381
|
+
"source",
|
|
1382
|
+
"line",
|
|
1383
|
+
"predicate",
|
|
1384
|
+
"target",
|
|
1385
|
+
"candidates",
|
|
1386
|
+
"candidatesTruncated",
|
|
1387
|
+
"message",
|
|
1388
|
+
],
|
|
1389
|
+
label,
|
|
1390
|
+
);
|
|
1391
|
+
if (parsedText(record.kind, `${label}.kind`, budget) !== "relation-issue") {
|
|
1392
|
+
throw new TypeError(`${label}.kind must be relation-issue.`);
|
|
1393
|
+
}
|
|
1394
|
+
if (
|
|
1395
|
+
record.issue !== "malformed"
|
|
1396
|
+
&& record.issue !== "broken"
|
|
1397
|
+
&& record.issue !== "ambiguous"
|
|
1398
|
+
) {
|
|
1399
|
+
throw new TypeError(`${label}.issue is unsupported.`);
|
|
1400
|
+
}
|
|
1401
|
+
const issue = parsedText(record.issue, `${label}.issue`, budget) as
|
|
1402
|
+
RelationIssueEvidence["issue"];
|
|
1403
|
+
const candidates = dataArray(
|
|
1404
|
+
record.candidates,
|
|
1405
|
+
`${label}.candidates`,
|
|
1406
|
+
MAX_PERCOLATION_EVIDENCE_PER_CANDIDATE,
|
|
1407
|
+
budget,
|
|
1408
|
+
).map((candidate, index) =>
|
|
1409
|
+
canonicalNote(candidate, `${label}.candidates[${index}]`, budget));
|
|
1410
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
1411
|
+
const previous = candidates[index - 1];
|
|
1412
|
+
const candidate = candidates[index];
|
|
1413
|
+
if (candidate === undefined) continue;
|
|
1414
|
+
if (previous !== undefined && compareText(previous, candidate) >= 0) {
|
|
1415
|
+
throw new TypeError(`${label}.candidates must be sorted and unique.`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (issue !== "ambiguous" && candidates.length !== 0) {
|
|
1419
|
+
throw new TypeError(`${label}.candidates are only valid for ambiguous issues.`);
|
|
1420
|
+
}
|
|
1421
|
+
if (issue === "ambiguous" && candidates.length < 2) {
|
|
1422
|
+
throw new TypeError(`${label}.candidates must identify at least two ambiguous notes.`);
|
|
1423
|
+
}
|
|
1424
|
+
const candidatesTruncated = parsedBoolean(
|
|
1425
|
+
record.candidatesTruncated,
|
|
1426
|
+
`${label}.candidatesTruncated`,
|
|
1427
|
+
);
|
|
1428
|
+
if (
|
|
1429
|
+
(issue !== "ambiguous" && candidatesTruncated)
|
|
1430
|
+
|| (candidatesTruncated
|
|
1431
|
+
&& candidates.length !== MAX_PERCOLATION_EVIDENCE_PER_CANDIDATE)
|
|
1432
|
+
) {
|
|
1433
|
+
throw new TypeError(`${label}.candidatesTruncated is inconsistent.`);
|
|
1434
|
+
}
|
|
1435
|
+
const predicate = issue === "malformed"
|
|
1436
|
+
? nullableText(record.predicate, `${label}.predicate`, budget)
|
|
1437
|
+
: canonicalPredicate(record.predicate, `${label}.predicate`, budget);
|
|
1438
|
+
const target = issue === "malformed"
|
|
1439
|
+
? nullableText(record.target, `${label}.target`, budget)
|
|
1440
|
+
: canonicalNote(record.target, `${label}.target`, budget);
|
|
1441
|
+
return Object.freeze({
|
|
1442
|
+
kind: "relation-issue",
|
|
1443
|
+
issue,
|
|
1444
|
+
source: canonicalNote(record.source, `${label}.source`, budget),
|
|
1445
|
+
line: positiveSafeInteger(record.line, `${label}.line`),
|
|
1446
|
+
predicate,
|
|
1447
|
+
target,
|
|
1448
|
+
candidates: Object.freeze(candidates),
|
|
1449
|
+
candidatesTruncated,
|
|
1450
|
+
message: parsedText(record.message, `${label}.message`, budget),
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function evidenceArray<T>(
|
|
1455
|
+
value: unknown,
|
|
1456
|
+
label: string,
|
|
1457
|
+
budget: ParseBudget,
|
|
1458
|
+
parse: (entry: unknown, label: string, budget: ParseBudget) => T,
|
|
1459
|
+
): readonly T[] {
|
|
1460
|
+
const input = dataArray(
|
|
1461
|
+
value,
|
|
1462
|
+
label,
|
|
1463
|
+
MAX_PERCOLATION_EVIDENCE_PER_CANDIDATE,
|
|
1464
|
+
budget,
|
|
1465
|
+
);
|
|
1466
|
+
if (input.length === 0) throw new TypeError(`${label} must not be empty.`);
|
|
1467
|
+
const output = input.map((entry, index) =>
|
|
1468
|
+
parse(entry, `${label}[${index}]`, budget));
|
|
1469
|
+
const identities = new Set<string>();
|
|
1470
|
+
for (const entry of output) {
|
|
1471
|
+
const identity = JSON.stringify(entry);
|
|
1472
|
+
if (identities.has(identity)) throw new TypeError(`${label} must be unique.`);
|
|
1473
|
+
identities.add(identity);
|
|
1474
|
+
}
|
|
1475
|
+
return Object.freeze(output);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function parsedRelationProblem(
|
|
1479
|
+
value: unknown,
|
|
1480
|
+
label: string,
|
|
1481
|
+
budget: ParseBudget,
|
|
1482
|
+
): RelationHygieneProblem {
|
|
1483
|
+
const parsed = parsedText(value, label, budget);
|
|
1484
|
+
if (
|
|
1485
|
+
parsed !== "self-relation"
|
|
1486
|
+
&& parsed !== "reciprocal-relation"
|
|
1487
|
+
&& parsed !== "malformed-relation"
|
|
1488
|
+
&& parsed !== "broken-relation"
|
|
1489
|
+
&& parsed !== "ambiguous-relation"
|
|
1490
|
+
) throw new TypeError(`${label} is unsupported.`);
|
|
1491
|
+
return parsed;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function parsedCommonCandidate(
|
|
1495
|
+
record: DataRecord,
|
|
1496
|
+
label: string,
|
|
1497
|
+
): {
|
|
1498
|
+
readonly support: number;
|
|
1499
|
+
readonly evidenceTruncated: boolean;
|
|
1500
|
+
} {
|
|
1501
|
+
return {
|
|
1502
|
+
support: positiveSafeInteger(record.support, `${label}.support`),
|
|
1503
|
+
evidenceTruncated: parsedBoolean(
|
|
1504
|
+
record.evidenceTruncated,
|
|
1505
|
+
`${label}.evidenceTruncated`,
|
|
1506
|
+
),
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function parseCandidate(
|
|
1511
|
+
value: unknown,
|
|
1512
|
+
label: string,
|
|
1513
|
+
budget: ParseBudget,
|
|
1514
|
+
version: 1 | 2,
|
|
1515
|
+
): PercolationCandidateV1 | PercolationCandidateV2 {
|
|
1516
|
+
const record = dataRecord(value, label, budget);
|
|
1517
|
+
const kind = parsedText(record.kind, `${label}.kind`, budget);
|
|
1518
|
+
if (kind === "missing-concept") {
|
|
1519
|
+
exactKeys(
|
|
1520
|
+
record,
|
|
1521
|
+
[
|
|
1522
|
+
"kind",
|
|
1523
|
+
"tag",
|
|
1524
|
+
"suggestedId",
|
|
1525
|
+
"collidesWith",
|
|
1526
|
+
"support",
|
|
1527
|
+
"evidenceTruncated",
|
|
1528
|
+
"evidence",
|
|
1529
|
+
],
|
|
1530
|
+
label,
|
|
1531
|
+
);
|
|
1532
|
+
const common = parsedCommonCandidate(record, label);
|
|
1533
|
+
const tag = parsedText(record.tag, `${label}.tag`, budget);
|
|
1534
|
+
const evidence = evidenceArray(
|
|
1535
|
+
record.evidence,
|
|
1536
|
+
`${label}.evidence`,
|
|
1537
|
+
budget,
|
|
1538
|
+
parsedMissingConceptEvidence,
|
|
1539
|
+
);
|
|
1540
|
+
if (evidence.some((entry) => entry.tag !== tag)) {
|
|
1541
|
+
throw new TypeError(`${label}.evidence must support the candidate tag.`);
|
|
1542
|
+
}
|
|
1543
|
+
if (
|
|
1544
|
+
(!common.evidenceTruncated && common.support !== evidence.length)
|
|
1545
|
+
|| (common.evidenceTruncated && common.support <= evidence.length)
|
|
1546
|
+
) {
|
|
1547
|
+
throw new TypeError(`${label}.support does not match its bounded evidence.`);
|
|
1548
|
+
}
|
|
1549
|
+
return Object.freeze({
|
|
1550
|
+
kind: "missing-concept",
|
|
1551
|
+
tag,
|
|
1552
|
+
suggestedId: canonicalNote(record.suggestedId, `${label}.suggestedId`, budget),
|
|
1553
|
+
collidesWith: record.collidesWith === null
|
|
1554
|
+
? null
|
|
1555
|
+
: canonicalNote(record.collidesWith, `${label}.collidesWith`, budget),
|
|
1556
|
+
...common,
|
|
1557
|
+
evidence,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
if (kind === "missing-relation") {
|
|
1561
|
+
exactKeys(
|
|
1562
|
+
record,
|
|
1563
|
+
version === 1
|
|
1564
|
+
? [
|
|
1565
|
+
"kind",
|
|
1566
|
+
"source",
|
|
1567
|
+
"target",
|
|
1568
|
+
"suggestedPredicate",
|
|
1569
|
+
"support",
|
|
1570
|
+
"evidenceTruncated",
|
|
1571
|
+
"evidence",
|
|
1572
|
+
]
|
|
1573
|
+
: [
|
|
1574
|
+
"kind",
|
|
1575
|
+
"source",
|
|
1576
|
+
"target",
|
|
1577
|
+
"predicate",
|
|
1578
|
+
"support",
|
|
1579
|
+
"evidenceTruncated",
|
|
1580
|
+
"evidence",
|
|
1581
|
+
],
|
|
1582
|
+
label,
|
|
1583
|
+
);
|
|
1584
|
+
const source = canonicalNote(record.source, `${label}.source`, budget);
|
|
1585
|
+
const target = canonicalNote(record.target, `${label}.target`, budget);
|
|
1586
|
+
if (compareText(source, target) >= 0) {
|
|
1587
|
+
throw new TypeError(`${label} endpoints must be an ordered, distinct pair.`);
|
|
1588
|
+
}
|
|
1589
|
+
const common = parsedCommonCandidate(record, label);
|
|
1590
|
+
const evidence = evidenceArray(
|
|
1591
|
+
record.evidence,
|
|
1592
|
+
`${label}.evidence`,
|
|
1593
|
+
budget,
|
|
1594
|
+
parsedSharedEvidence,
|
|
1595
|
+
);
|
|
1596
|
+
if (evidence.some((entry) => entry.note !== source && entry.note !== target)) {
|
|
1597
|
+
throw new TypeError(`${label}.evidence must belong to one of the unordered endpoints.`);
|
|
1598
|
+
}
|
|
1599
|
+
const signalEndpoints = new Map<string, Set<string>>();
|
|
1600
|
+
for (const entry of evidence) {
|
|
1601
|
+
const signal = entry.kind === "shared-tag"
|
|
1602
|
+
? `tag\u0000${entry.tag}`
|
|
1603
|
+
: `concept\u0000${entry.concept}`;
|
|
1604
|
+
const endpoints = signalEndpoints.get(signal) ?? new Set<string>();
|
|
1605
|
+
endpoints.add(entry.note);
|
|
1606
|
+
signalEndpoints.set(signal, endpoints);
|
|
1607
|
+
}
|
|
1608
|
+
if ([...signalEndpoints.values()].some((endpoints) =>
|
|
1609
|
+
endpoints.size !== 2 || !endpoints.has(source) || !endpoints.has(target))) {
|
|
1610
|
+
throw new TypeError(`${label}.evidence must pair both unordered endpoints per signal.`);
|
|
1611
|
+
}
|
|
1612
|
+
if (
|
|
1613
|
+
(!common.evidenceTruncated && common.support !== signalEndpoints.size)
|
|
1614
|
+
|| (common.evidenceTruncated
|
|
1615
|
+
&& (
|
|
1616
|
+
evidence.length !== MAX_PERCOLATION_EVIDENCE_PER_CANDIDATE
|
|
1617
|
+
|| common.support <= signalEndpoints.size
|
|
1618
|
+
))
|
|
1619
|
+
) {
|
|
1620
|
+
throw new TypeError(`${label}.support does not match its bounded shared signals.`);
|
|
1621
|
+
}
|
|
1622
|
+
if (version === 1) {
|
|
1623
|
+
if (
|
|
1624
|
+
parsedText(
|
|
1625
|
+
record.suggestedPredicate,
|
|
1626
|
+
`${label}.suggestedPredicate`,
|
|
1627
|
+
budget,
|
|
1628
|
+
) !== "related-to"
|
|
1629
|
+
) {
|
|
1630
|
+
throw new TypeError(`${label}.suggestedPredicate must be related-to.`);
|
|
1631
|
+
}
|
|
1632
|
+
return Object.freeze({
|
|
1633
|
+
kind: "missing-relation",
|
|
1634
|
+
source,
|
|
1635
|
+
target,
|
|
1636
|
+
suggestedPredicate: "related-to",
|
|
1637
|
+
...common,
|
|
1638
|
+
evidence,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
return Object.freeze({
|
|
1642
|
+
kind: "missing-relation",
|
|
1643
|
+
source,
|
|
1644
|
+
target,
|
|
1645
|
+
predicate: predicateDisposition(record.predicate, `${label}.predicate`, budget),
|
|
1646
|
+
...common,
|
|
1647
|
+
evidence,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
if (kind === "unlinked-mention") {
|
|
1651
|
+
exactKeys(
|
|
1652
|
+
record,
|
|
1653
|
+
["kind", "source", "target", "support", "evidenceTruncated", "evidence"],
|
|
1654
|
+
label,
|
|
1655
|
+
);
|
|
1656
|
+
const source = canonicalNote(record.source, `${label}.source`, budget);
|
|
1657
|
+
const target = canonicalNote(record.target, `${label}.target`, budget);
|
|
1658
|
+
const common = parsedCommonCandidate(record, label);
|
|
1659
|
+
const evidence = evidenceArray(
|
|
1660
|
+
record.evidence,
|
|
1661
|
+
`${label}.evidence`,
|
|
1662
|
+
budget,
|
|
1663
|
+
parsedMentionEvidence,
|
|
1664
|
+
);
|
|
1665
|
+
if (evidence.some((entry) => entry.source !== source || entry.target !== target)) {
|
|
1666
|
+
throw new TypeError(`${label}.evidence must identify the candidate endpoints.`);
|
|
1667
|
+
}
|
|
1668
|
+
if (
|
|
1669
|
+
(!common.evidenceTruncated && common.support !== evidence.length)
|
|
1670
|
+
|| (common.evidenceTruncated && common.support <= evidence.length)
|
|
1671
|
+
) {
|
|
1672
|
+
throw new TypeError(`${label}.support does not match its bounded evidence.`);
|
|
1673
|
+
}
|
|
1674
|
+
return Object.freeze({
|
|
1675
|
+
kind: "unlinked-mention",
|
|
1676
|
+
source,
|
|
1677
|
+
target,
|
|
1678
|
+
...common,
|
|
1679
|
+
evidence,
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
if (kind === "relation-hygiene") {
|
|
1683
|
+
exactKeys(
|
|
1684
|
+
record,
|
|
1685
|
+
[
|
|
1686
|
+
"kind",
|
|
1687
|
+
"problem",
|
|
1688
|
+
"source",
|
|
1689
|
+
"target",
|
|
1690
|
+
"predicate",
|
|
1691
|
+
"message",
|
|
1692
|
+
"support",
|
|
1693
|
+
"evidenceTruncated",
|
|
1694
|
+
"evidence",
|
|
1695
|
+
],
|
|
1696
|
+
label,
|
|
1697
|
+
);
|
|
1698
|
+
const problem = parsedRelationProblem(record.problem, `${label}.problem`, budget);
|
|
1699
|
+
const source = canonicalNote(record.source, `${label}.source`, budget);
|
|
1700
|
+
const target = problem === "malformed-relation"
|
|
1701
|
+
? nullableText(record.target, `${label}.target`, budget)
|
|
1702
|
+
: record.target === null
|
|
1703
|
+
? null
|
|
1704
|
+
: canonicalNote(record.target, `${label}.target`, budget);
|
|
1705
|
+
const predicate = problem === "malformed-relation"
|
|
1706
|
+
? nullableText(record.predicate, `${label}.predicate`, budget)
|
|
1707
|
+
: record.predicate === null
|
|
1708
|
+
? null
|
|
1709
|
+
: canonicalPredicate(record.predicate, `${label}.predicate`, budget);
|
|
1710
|
+
const common = parsedCommonCandidate(record, label);
|
|
1711
|
+
const relationProblem = problem === "self-relation"
|
|
1712
|
+
|| problem === "reciprocal-relation";
|
|
1713
|
+
const evidence = relationProblem
|
|
1714
|
+
? evidenceArray(
|
|
1715
|
+
record.evidence,
|
|
1716
|
+
`${label}.evidence`,
|
|
1717
|
+
budget,
|
|
1718
|
+
parsedRelationEvidence,
|
|
1719
|
+
)
|
|
1720
|
+
: evidenceArray(
|
|
1721
|
+
record.evidence,
|
|
1722
|
+
`${label}.evidence`,
|
|
1723
|
+
budget,
|
|
1724
|
+
parsedRelationIssueEvidence,
|
|
1725
|
+
);
|
|
1726
|
+
if (common.support !== evidence.length) {
|
|
1727
|
+
throw new TypeError(`${label}.support must equal its hygiene evidence count.`);
|
|
1728
|
+
}
|
|
1729
|
+
if (relationProblem) {
|
|
1730
|
+
const relations = evidence as readonly RelationEvidence[];
|
|
1731
|
+
if (
|
|
1732
|
+
target === null
|
|
1733
|
+
|| predicate === null
|
|
1734
|
+
|| common.evidenceTruncated
|
|
1735
|
+
|| (problem === "self-relation" && target !== source)
|
|
1736
|
+
|| (problem === "reciprocal-relation"
|
|
1737
|
+
&& (compareText(source, target) >= 0 || relations.length !== 2))
|
|
1738
|
+
|| relations.some((entry) =>
|
|
1739
|
+
entry.predicate !== predicate
|
|
1740
|
+
|| (problem === "self-relation"
|
|
1741
|
+
? entry.source !== source || entry.target !== target
|
|
1742
|
+
: !(
|
|
1743
|
+
(entry.source === source && entry.target === target)
|
|
1744
|
+
|| (entry.source === target && entry.target === source)
|
|
1745
|
+
)))
|
|
1746
|
+
) throw new TypeError(`${label}.evidence must identify the hygiene relation.`);
|
|
1747
|
+
} else {
|
|
1748
|
+
const issues = evidence as readonly RelationIssueEvidence[];
|
|
1749
|
+
const expectedIssue = problem.slice(0, -"-relation".length);
|
|
1750
|
+
if (issues.some((entry) =>
|
|
1751
|
+
entry.source !== source
|
|
1752
|
+
|| entry.issue !== expectedIssue
|
|
1753
|
+
|| entry.predicate !== predicate
|
|
1754
|
+
|| entry.target !== target
|
|
1755
|
+
|| entry.message !== record.message)
|
|
1756
|
+
|| common.evidenceTruncated
|
|
1757
|
+
!== issues.some((entry) => entry.candidatesTruncated)) {
|
|
1758
|
+
throw new TypeError(`${label}.evidence must identify the hygiene issue.`);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
return Object.freeze({
|
|
1762
|
+
kind: "relation-hygiene",
|
|
1763
|
+
problem,
|
|
1764
|
+
source,
|
|
1765
|
+
target,
|
|
1766
|
+
predicate,
|
|
1767
|
+
message: parsedText(record.message, `${label}.message`, budget),
|
|
1768
|
+
...common,
|
|
1769
|
+
evidence,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
throw new TypeError(`${label}.kind is unsupported.`);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function parsedCandidates<V extends 1 | 2>(
|
|
1776
|
+
value: unknown,
|
|
1777
|
+
label: string,
|
|
1778
|
+
budget: ParseBudget,
|
|
1779
|
+
version: V,
|
|
1780
|
+
): readonly (V extends 1 ? PercolationCandidateV1 : PercolationCandidateV2)[] {
|
|
1781
|
+
const input = dataArray(value, label, MAX_PERCOLATION_LIMIT, budget);
|
|
1782
|
+
const output = input.map((entry, index) =>
|
|
1783
|
+
parseCandidate(entry, `${label}[${index}]`, budget, version));
|
|
1784
|
+
if (version === 1) {
|
|
1785
|
+
const historical = output as readonly PercolationCandidateV1[];
|
|
1786
|
+
for (let index = 1; index < historical.length; index += 1) {
|
|
1787
|
+
const previous = historical[index - 1];
|
|
1788
|
+
const candidate = historical[index];
|
|
1789
|
+
if (
|
|
1790
|
+
previous !== undefined
|
|
1791
|
+
&& candidate !== undefined
|
|
1792
|
+
&& compareHistoricalCandidates(previous, candidate) > 0
|
|
1793
|
+
) {
|
|
1794
|
+
throw new TypeError(`${label} must use historical percolation ordering.`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return Object.freeze([...historical]) as readonly (
|
|
1798
|
+
V extends 1 ? PercolationCandidateV1 : PercolationCandidateV2
|
|
1799
|
+
)[];
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const identities = new Set<string>();
|
|
1803
|
+
const suggestedConceptIds = new Set<string>();
|
|
1804
|
+
for (let index = 0; index < output.length; index += 1) {
|
|
1805
|
+
const candidate = output[index];
|
|
1806
|
+
if (candidate === undefined) continue;
|
|
1807
|
+
const identity = `${candidate.kind}\u0000${candidateIdentity(candidate)}`;
|
|
1808
|
+
if (identities.has(identity)) throw new TypeError(`${label} must be unique.`);
|
|
1809
|
+
identities.add(identity);
|
|
1810
|
+
if (candidate.kind === "missing-concept") {
|
|
1811
|
+
if (suggestedConceptIds.has(candidate.suggestedId)) {
|
|
1812
|
+
throw new TypeError(`${label} must use unique suggested concept IDs.`);
|
|
1813
|
+
}
|
|
1814
|
+
suggestedConceptIds.add(candidate.suggestedId);
|
|
1815
|
+
}
|
|
1816
|
+
const previous = output[index - 1];
|
|
1817
|
+
if (previous !== undefined && compareCandidates(previous, candidate) > 0) {
|
|
1818
|
+
throw new TypeError(`${label} must use canonical percolation ordering.`);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return Object.freeze(output) as readonly (
|
|
1822
|
+
V extends 1 ? PercolationCandidateV1 : PercolationCandidateV2
|
|
1823
|
+
)[];
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function parseResultFields<V extends 1 | 2>(
|
|
1827
|
+
record: DataRecord,
|
|
1828
|
+
label: string,
|
|
1829
|
+
budget: ParseBudget,
|
|
1830
|
+
version: V,
|
|
1831
|
+
): {
|
|
1832
|
+
readonly candidates: readonly (
|
|
1833
|
+
V extends 1 ? PercolationCandidateV1 : PercolationCandidateV2
|
|
1834
|
+
)[];
|
|
1835
|
+
readonly truncated: boolean;
|
|
1836
|
+
} {
|
|
1837
|
+
return {
|
|
1838
|
+
candidates: parsedCandidates(record.candidates, `${label}.candidates`, budget, version),
|
|
1839
|
+
truncated: parsedBoolean(record.truncated, `${label}.truncated`),
|
|
876
1840
|
};
|
|
877
1841
|
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* Parse only the exact historical unversioned core result shape. This parser
|
|
1845
|
+
* deliberately preserves `related-to`; it never guesses a V2 disposition.
|
|
1846
|
+
* @deprecated Retained through 0.19.x; removable no earlier than 0.20.0.
|
|
1847
|
+
*/
|
|
1848
|
+
export function parsePercolationResultV1(value: unknown): PercolationResultV1 {
|
|
1849
|
+
const budget: ParseBudget = { nodes: 0, utf8Bytes: 0 };
|
|
1850
|
+
const record = dataRecord(value, "percolation result v1", budget);
|
|
1851
|
+
exactKeys(record, ["candidates", "truncated"], "percolation result v1");
|
|
1852
|
+
const fields = parseResultFields(record, "percolation result v1", budget, 1);
|
|
1853
|
+
return Object.freeze({
|
|
1854
|
+
candidates: fields.candidates,
|
|
1855
|
+
truncated: fields.truncated,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** Parse only the exact V2 core result shape; CLI envelopes are rejected. */
|
|
1860
|
+
export function parsePercolationResultV2(value: unknown): PercolationResultV2 {
|
|
1861
|
+
const budget: ParseBudget = { nodes: 0, utf8Bytes: 0 };
|
|
1862
|
+
const record = dataRecord(value, "percolation result v2", budget);
|
|
1863
|
+
exactKeys(
|
|
1864
|
+
record,
|
|
1865
|
+
["schemaVersion", "candidates", "truncated"],
|
|
1866
|
+
"percolation result v2",
|
|
1867
|
+
);
|
|
1868
|
+
if (record.schemaVersion !== PERCOLATION_RESULT_SCHEMA_VERSION) {
|
|
1869
|
+
throw new TypeError("percolation result v2.schemaVersion must be 2.");
|
|
1870
|
+
}
|
|
1871
|
+
const fields = parseResultFields(record, "percolation result v2", budget, 2);
|
|
1872
|
+
return Object.freeze({
|
|
1873
|
+
schemaVersion: PERCOLATION_RESULT_SCHEMA_VERSION,
|
|
1874
|
+
candidates: fields.candidates,
|
|
1875
|
+
truncated: fields.truncated,
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
export const parsePercolationResult = parsePercolationResultV2;
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* Parse only the exact historical V1 CLI envelope without upgrading it.
|
|
1883
|
+
* @deprecated Retained through 0.19.x; removable no earlier than 0.20.0.
|
|
1884
|
+
*/
|
|
1885
|
+
export function parsePercolationCliOutputV1(value: unknown): PercolationCliOutputV1 {
|
|
1886
|
+
const budget: ParseBudget = { nodes: 0, utf8Bytes: 0 };
|
|
1887
|
+
const label = "percolation CLI output v1";
|
|
1888
|
+
const record = dataRecord(value, label, budget);
|
|
1889
|
+
exactKeys(
|
|
1890
|
+
record,
|
|
1891
|
+
["root", "note", "minSupport", "candidates", "truncated"],
|
|
1892
|
+
label,
|
|
1893
|
+
);
|
|
1894
|
+
const fields = parseResultFields(record, label, budget, 1);
|
|
1895
|
+
return Object.freeze({
|
|
1896
|
+
root: parsedText(record.root, `${label}.root`, budget),
|
|
1897
|
+
note: nullableText(record.note, `${label}.note`, budget),
|
|
1898
|
+
minSupport: parsedMinSupport(record.minSupport, `${label}.minSupport`),
|
|
1899
|
+
candidates: fields.candidates,
|
|
1900
|
+
truncated: fields.truncated,
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/** Parse only the exact current V2 CLI envelope. */
|
|
1905
|
+
export function parsePercolationCliOutputV2(value: unknown): PercolationCliOutputV2 {
|
|
1906
|
+
const budget: ParseBudget = { nodes: 0, utf8Bytes: 0 };
|
|
1907
|
+
const label = "percolation CLI output v2";
|
|
1908
|
+
const record = dataRecord(value, label, budget);
|
|
1909
|
+
exactKeys(
|
|
1910
|
+
record,
|
|
1911
|
+
[
|
|
1912
|
+
"root",
|
|
1913
|
+
"note",
|
|
1914
|
+
"minSupport",
|
|
1915
|
+
"limit",
|
|
1916
|
+
"schemaVersion",
|
|
1917
|
+
"candidates",
|
|
1918
|
+
"truncated",
|
|
1919
|
+
],
|
|
1920
|
+
label,
|
|
1921
|
+
);
|
|
1922
|
+
if (record.schemaVersion !== PERCOLATION_RESULT_SCHEMA_VERSION) {
|
|
1923
|
+
throw new TypeError(`${label}.schemaVersion must be 2.`);
|
|
1924
|
+
}
|
|
1925
|
+
const fields = parseResultFields(record, label, budget, 2);
|
|
1926
|
+
const limit = positiveSafeInteger(
|
|
1927
|
+
record.limit,
|
|
1928
|
+
`${label}.limit`,
|
|
1929
|
+
MAX_PERCOLATION_LIMIT,
|
|
1930
|
+
);
|
|
1931
|
+
if (
|
|
1932
|
+
fields.candidates.length > limit
|
|
1933
|
+
|| (fields.truncated && fields.candidates.length !== limit)
|
|
1934
|
+
) {
|
|
1935
|
+
throw new TypeError(`${label}.limit is inconsistent with its candidates.`);
|
|
1936
|
+
}
|
|
1937
|
+
return Object.freeze({
|
|
1938
|
+
root: parsedText(record.root, `${label}.root`, budget),
|
|
1939
|
+
note: nullableText(record.note, `${label}.note`, budget),
|
|
1940
|
+
minSupport: parsedMinSupport(record.minSupport, `${label}.minSupport`),
|
|
1941
|
+
limit,
|
|
1942
|
+
schemaVersion: PERCOLATION_RESULT_SCHEMA_VERSION,
|
|
1943
|
+
candidates: fields.candidates,
|
|
1944
|
+
truncated: fields.truncated,
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
export const parsePercolationCliOutput = parsePercolationCliOutputV2;
|