@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.
Files changed (39) hide show
  1. package/README.md +197 -113
  2. package/dist/authoring.js +2 -2
  3. package/dist/benchmark.js +3 -3
  4. package/dist/cli.js +16 -12
  5. package/dist/evaluation-builder.js +5 -5
  6. package/dist/evaluation-kb.js +5 -5
  7. package/dist/graph.js +3 -1
  8. package/dist/{index-zxdy5pby.js → index-5m2ydj5q.js} +2 -2
  9. package/dist/{index-cxfrakt7.js → index-ekpwvbra.js} +5 -2
  10. package/dist/{index-jsmvyyvf.js → index-ey46z1zf.js} +4 -4
  11. package/dist/{index-cv6fh7z5.js → index-gm9t95d9.js} +1 -1
  12. package/dist/{index-01jj6rbv.js → index-gxr0fctd.js} +3 -3
  13. package/dist/index-nd6nynv2.js +1162 -0
  14. package/dist/{index-s2gw5aw9.js → index-qwgsmtsz.js} +1 -1
  15. package/dist/{index-zzhgcwyt.js → index-vxmf14m1.js} +3 -3
  16. package/dist/{index-n5dd7r0v.js → index-xw9ac71d.js} +2 -2
  17. package/dist/{index-1vrd1rmn.js → index-ykvvkd77.js} +1 -1
  18. package/dist/index.js +30 -8
  19. package/dist/percolate.js +22 -2
  20. package/dist/portfolio.js +5 -5
  21. package/dist/sdk.js +4 -4
  22. package/dist/search.js +2 -2
  23. package/dist/semantic.js +3 -3
  24. package/dist/workflows/decision-context.js +5 -5
  25. package/dist/workflows/index.js +5 -5
  26. package/package.json +1 -1
  27. package/skills/kb/AGENTS.md +3 -0
  28. package/skills/kb/SKILL.md +38 -29
  29. package/skills/kb/agents/openai.yaml +2 -2
  30. package/skills/kb/references/companion-skills.md +96 -0
  31. package/skills/kb/references/customize.md +123 -0
  32. package/skills/kb/references/percolate.md +39 -7
  33. package/skills/kb/references/query.md +21 -0
  34. package/skills/kb/templates/companion-skill.template.md +57 -0
  35. package/src/authoring.ts +5 -3
  36. package/src/cli.ts +12 -7
  37. package/src/graph.ts +8 -1
  38. package/src/percolate.ts +1088 -17
  39. 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 MissingRelationCandidate = {
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 PercolationCandidate =
148
+ export type PercolationCandidateV1 =
126
149
  | MissingConceptCandidate
127
- | MissingRelationCandidate
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
- export type PercolationResult = {
140
- readonly candidates: readonly PercolationCandidate[];
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) return { id: natural, 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
- if (!occupiedIds.has(suffixed.toLocaleLowerCase("en-US"))) {
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
- for (let suffix = 2; suffix <= MAX_PERCOLATION_NOTES + 2; suffix += 1) {
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
- if (!occupiedIds.has(candidate.toLocaleLowerCase("en-US"))) {
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: PercolationCandidate): string {
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<PercolationCandidate["kind"], number>> = {
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: PercolationCandidate,
422
- right: PercolationCandidate,
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(tag, occupiedIds);
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
- suggestedPredicate: "related-to",
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 sorted = candidates
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;