@fungi.computer/forage 0.0.0-stage → 0.0.2
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/LICENSE +24 -0
- package/README.md +223 -2
- package/THIRD_PARTY_NOTICES +13 -0
- package/dist/app-api.d.ts +62 -0
- package/dist/app-api.js +121 -0
- package/dist/catalog.d.ts +16 -0
- package/dist/catalog.js +108 -0
- package/dist/definitions.d.ts +16 -0
- package/dist/definitions.js +25 -0
- package/dist/describe.d.ts +4 -0
- package/dist/describe.js +66 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +16 -0
- package/dist/json-schema-boundary.d.ts +10 -0
- package/dist/json-schema-boundary.js +56 -0
- package/dist/json-schema-subset.d.ts +6 -0
- package/dist/json-schema-subset.js +77 -0
- package/dist/list.d.ts +8 -0
- package/dist/list.js +53 -0
- package/dist/normalization.d.ts +22 -0
- package/dist/normalization.js +109 -0
- package/dist/openapi.d.ts +106 -0
- package/dist/openapi.js +519 -0
- package/dist/rendering.d.ts +26 -0
- package/dist/rendering.js +341 -0
- package/dist/schemas.d.ts +231 -0
- package/dist/schemas.js +156 -0
- package/dist/search.d.ts +17 -0
- package/dist/search.js +258 -0
- package/docs/api/README.md +15 -0
- package/docs/api/index.md +859 -0
- package/docs/api/json-schema-subset.md +42 -0
- package/docs/api/openapi.md +308 -0
- package/package.json +71 -3
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const pathPattern = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
|
3
|
+
const maximumCatalogOperations = 1_000;
|
|
4
|
+
const maximumScore = 221;
|
|
5
|
+
export const maximumDescriptionTypes = 20_000;
|
|
6
|
+
export const maximumSampleEntries = 100;
|
|
7
|
+
export const maximumSampleBytes = 4_000;
|
|
8
|
+
export const maximumSampleDepth = 16;
|
|
9
|
+
function jsonDepth(value, depth) {
|
|
10
|
+
if (depth > maximumSampleDepth)
|
|
11
|
+
return depth;
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
let maximum = depth;
|
|
14
|
+
for (const item of value) {
|
|
15
|
+
maximum = Math.max(maximum, jsonDepth(item, depth + 1));
|
|
16
|
+
}
|
|
17
|
+
return maximum;
|
|
18
|
+
}
|
|
19
|
+
if (value === null)
|
|
20
|
+
return depth;
|
|
21
|
+
const record = z.record(z.string(), z.json()).safeParse(value);
|
|
22
|
+
if (!record.success)
|
|
23
|
+
return depth;
|
|
24
|
+
let maximum = depth;
|
|
25
|
+
for (const item of Object.values(record.data)) {
|
|
26
|
+
maximum = Math.max(maximum, jsonDepth(item, depth + 1));
|
|
27
|
+
}
|
|
28
|
+
return maximum;
|
|
29
|
+
}
|
|
30
|
+
const sampleSchema = z
|
|
31
|
+
.record(z.string(), z.json())
|
|
32
|
+
.superRefine((value, context) => {
|
|
33
|
+
if (Object.keys(value).length > maximumSampleEntries) {
|
|
34
|
+
context.addIssue({
|
|
35
|
+
code: "custom",
|
|
36
|
+
message: "Sample exceeds the entry bound",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (JSON.stringify(value).length > maximumSampleBytes) {
|
|
40
|
+
context.addIssue({
|
|
41
|
+
code: "custom",
|
|
42
|
+
message: "Sample exceeds the byte bound",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (jsonDepth(value, 0) > maximumSampleDepth) {
|
|
46
|
+
context.addIssue({
|
|
47
|
+
code: "custom",
|
|
48
|
+
message: "Sample exceeds the depth bound",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
/** Input accepted by the pure catalog search seam. */
|
|
53
|
+
export const forageSearchInput = z.object({
|
|
54
|
+
query: z.string().max(200),
|
|
55
|
+
maxResults: z.number().int().min(1).max(50).default(50),
|
|
56
|
+
});
|
|
57
|
+
/** Input accepted by the pure catalog description seam. */
|
|
58
|
+
export const forageDescribeInput = z.object({
|
|
59
|
+
path: z.string().max(160),
|
|
60
|
+
detail: z.enum(["index", "full"]).default("full"),
|
|
61
|
+
methods: z.array(z.string().max(80)).max(50).optional(),
|
|
62
|
+
});
|
|
63
|
+
/** Exhaustive listing request; cursor is scoped to one immutable catalog and filter. */
|
|
64
|
+
export const forageListInput = z.object({
|
|
65
|
+
cursor: z.string().max(400).optional(),
|
|
66
|
+
limit: z.number().int().min(1).max(50).default(50),
|
|
67
|
+
namespace: z
|
|
68
|
+
.string()
|
|
69
|
+
.min(1)
|
|
70
|
+
.max(160)
|
|
71
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/)
|
|
72
|
+
.optional(),
|
|
73
|
+
});
|
|
74
|
+
const peerSchema = z.object({
|
|
75
|
+
path: z.string().max(160),
|
|
76
|
+
capabilityNotes: z.string().max(500).optional(),
|
|
77
|
+
});
|
|
78
|
+
/** One ranked operation in a search response. */
|
|
79
|
+
export const searchResultSchema = z.object({
|
|
80
|
+
path: z.string().max(160),
|
|
81
|
+
description: z.string().max(1000).optional(),
|
|
82
|
+
score: z.number().min(0).max(maximumScore),
|
|
83
|
+
matchReason: z.string().max(500).optional(),
|
|
84
|
+
sample: sampleSchema.optional(),
|
|
85
|
+
capabilityNotes: z.string().max(500).optional(),
|
|
86
|
+
peers: z.array(peerSchema).max(1000).optional(),
|
|
87
|
+
});
|
|
88
|
+
/** Bounded search response envelope. */
|
|
89
|
+
export const searchOutputSchema = z.object({
|
|
90
|
+
results: z.array(searchResultSchema).max(50),
|
|
91
|
+
total: z.number().int().min(0).max(maximumCatalogOperations),
|
|
92
|
+
truncated: z.boolean(),
|
|
93
|
+
topScore: z.number().min(0).max(maximumScore),
|
|
94
|
+
margin: z.number().min(0).max(maximumScore),
|
|
95
|
+
catalogStamp: z
|
|
96
|
+
.string()
|
|
97
|
+
.length(16)
|
|
98
|
+
.regex(/^[0-9a-f]{16}$/),
|
|
99
|
+
});
|
|
100
|
+
/** One exhaustive page, using the exact search operation summary schema. */
|
|
101
|
+
export const listOutputSchema = z.object({
|
|
102
|
+
results: z.array(searchResultSchema).max(50),
|
|
103
|
+
total: z.number().int().min(0).max(maximumCatalogOperations),
|
|
104
|
+
nextCursor: z.string().max(400).nullable(),
|
|
105
|
+
catalogStamp: z
|
|
106
|
+
.string()
|
|
107
|
+
.length(16)
|
|
108
|
+
.regex(/^[0-9a-f]{16}$/),
|
|
109
|
+
});
|
|
110
|
+
/** Deterministic description response. */
|
|
111
|
+
export const describeOutputSchema = z.object({
|
|
112
|
+
path: z.string().max(160),
|
|
113
|
+
description: z.string().max(1000).optional(),
|
|
114
|
+
types: z.string().max(20000),
|
|
115
|
+
inputSchema: z
|
|
116
|
+
.json()
|
|
117
|
+
.refine((value) => JSON.stringify(value).length <= maximumDescriptionTypes &&
|
|
118
|
+
jsonDepth(value, 0) <= maximumSampleDepth, "Input schema exceeds the description bounds")
|
|
119
|
+
.optional(),
|
|
120
|
+
kind: z.enum(["module", "operation"]),
|
|
121
|
+
usage: z.string().max(300).optional(),
|
|
122
|
+
});
|
|
123
|
+
/** Closed failure vocabulary for malformed or absent catalog requests. */
|
|
124
|
+
export const forageFailureSchema = z.object({
|
|
125
|
+
code: z.enum([
|
|
126
|
+
"empty_query",
|
|
127
|
+
"empty_catalog",
|
|
128
|
+
"unknown_target",
|
|
129
|
+
"duplicate_operation",
|
|
130
|
+
"invalid_input",
|
|
131
|
+
]),
|
|
132
|
+
message: z.string().max(1000),
|
|
133
|
+
});
|
|
134
|
+
const jsonSchemaDescriber = z.object({
|
|
135
|
+
"~standard": z.object({
|
|
136
|
+
jsonSchema: z.object({
|
|
137
|
+
input: z.instanceof(Function),
|
|
138
|
+
output: z.instanceof(Function),
|
|
139
|
+
}),
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
const jsonSchemaSource = z.custom((value) => jsonSchemaDescriber.safeParse(value).success, "Expected a Standard JSON Schema");
|
|
143
|
+
/** A secret-free, callback-free operation descriptor. */
|
|
144
|
+
export const operationSchema = z.object({
|
|
145
|
+
path: z.string().min(3).max(160).regex(pathPattern),
|
|
146
|
+
description: z.string().max(1000).optional(),
|
|
147
|
+
input: jsonSchemaSource,
|
|
148
|
+
output: jsonSchemaSource.optional(),
|
|
149
|
+
capabilityNotes: z.string().max(500).optional(),
|
|
150
|
+
instructions: z.string().max(1000).optional(),
|
|
151
|
+
});
|
|
152
|
+
/** Input used to construct one immutable catalog revision. */
|
|
153
|
+
export const catalogInputSchema = z.object({
|
|
154
|
+
revision: z.string().min(1).max(160),
|
|
155
|
+
operations: z.array(operationSchema).max(maximumCatalogOperations),
|
|
156
|
+
});
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Operation, type SearchOutput } from "./schemas.js";
|
|
2
|
+
import { type RenderedOperation } from "./rendering.js";
|
|
3
|
+
import type { JsonValue } from "./normalization.js";
|
|
4
|
+
/** Private installation-time projection used by the pure ranking function. */
|
|
5
|
+
export type PreparedOperation = Operation & {
|
|
6
|
+
readonly fingerprint: string;
|
|
7
|
+
readonly inputSchema: JsonValue;
|
|
8
|
+
readonly sample: Record<string, JsonValue> | undefined;
|
|
9
|
+
readonly rendered: RenderedOperation;
|
|
10
|
+
};
|
|
11
|
+
/** Prepare schema-derived search metadata once for an immutable catalog revision. */
|
|
12
|
+
export declare function prepareOperation(operation: Operation): PreparedOperation;
|
|
13
|
+
/** Identify every model-visible part of the catalog, including schemas. */
|
|
14
|
+
declare function catalogStamp(operations: readonly PreparedOperation[]): string;
|
|
15
|
+
/** Pure deterministic ranking over one immutable operation snapshot. */
|
|
16
|
+
export declare function searchCatalog(operations: readonly PreparedOperation[], query: string, maxResults: number | undefined, stamp: string): SearchOutput;
|
|
17
|
+
export { catalogStamp };
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { searchOutputSchema, } from "./schemas.js";
|
|
2
|
+
import { canonicalStringify, compareCodeUnits, hashString64, isStopword, normalizeQuery, operationMethod, operationNamespace, stemVariants, tokenize, } from "./normalization.js";
|
|
3
|
+
import { deriveSample, inputSchemaProjection, renderOperation, schemaFingerprint, } from "./rendering.js";
|
|
4
|
+
import { ForageError } from "./errors.js";
|
|
5
|
+
/** Prepare schema-derived search metadata once for an immutable catalog revision. */
|
|
6
|
+
export function prepareOperation(operation) {
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
...operation,
|
|
9
|
+
fingerprint: schemaFingerprint(operation.input),
|
|
10
|
+
inputSchema: inputSchemaProjection(operation.input),
|
|
11
|
+
sample: deriveSample(operation.input),
|
|
12
|
+
rendered: renderOperation(operation),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
const matchWeight = {
|
|
16
|
+
exact: 14,
|
|
17
|
+
startsWith: 9,
|
|
18
|
+
phrase: 6,
|
|
19
|
+
token: 4,
|
|
20
|
+
prefix: 2,
|
|
21
|
+
substring: 2,
|
|
22
|
+
includes: 1,
|
|
23
|
+
};
|
|
24
|
+
function fieldWeight(field) {
|
|
25
|
+
switch (field) {
|
|
26
|
+
case "path":
|
|
27
|
+
return 12;
|
|
28
|
+
case "namespace":
|
|
29
|
+
return 8;
|
|
30
|
+
case "method":
|
|
31
|
+
return 10;
|
|
32
|
+
case "description":
|
|
33
|
+
return 5;
|
|
34
|
+
default:
|
|
35
|
+
return 5;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Identify every model-visible part of the catalog, including schemas. */
|
|
39
|
+
function catalogStamp(operations) {
|
|
40
|
+
const lines = [...operations]
|
|
41
|
+
.sort((left, right) => compareCodeUnits(left.path, right.path))
|
|
42
|
+
.map((operation) => canonicalStringify([
|
|
43
|
+
operation.path,
|
|
44
|
+
operation.description ?? null,
|
|
45
|
+
operation.capabilityNotes ?? null,
|
|
46
|
+
operation.instructions ?? null,
|
|
47
|
+
operation.fingerprint,
|
|
48
|
+
operation.output === undefined
|
|
49
|
+
? null
|
|
50
|
+
: schemaFingerprint(operation.output, "output"),
|
|
51
|
+
]));
|
|
52
|
+
return hashString64(lines.join("\n"));
|
|
53
|
+
}
|
|
54
|
+
function scoreLevel(value, term) {
|
|
55
|
+
if (value === term)
|
|
56
|
+
return "exact";
|
|
57
|
+
if (value.startsWith(term))
|
|
58
|
+
return "startsWith";
|
|
59
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
60
|
+
if (new RegExp(`\\b${escaped}\\b`).test(value))
|
|
61
|
+
return "phrase";
|
|
62
|
+
const parts = tokenize(value);
|
|
63
|
+
if (parts.some((part) => part === term))
|
|
64
|
+
return "token";
|
|
65
|
+
if (parts.some((part) => part.startsWith(term)))
|
|
66
|
+
return "prefix";
|
|
67
|
+
if (parts.some((part) => part.includes(term)))
|
|
68
|
+
return "substring";
|
|
69
|
+
if (value.includes(term))
|
|
70
|
+
return "includes";
|
|
71
|
+
return "none";
|
|
72
|
+
}
|
|
73
|
+
function scoreField(rawValue, field, queryTokens) {
|
|
74
|
+
const value = normalizeQuery(rawValue);
|
|
75
|
+
let bestLevel = "none";
|
|
76
|
+
let bestScore = 0;
|
|
77
|
+
let matchedTokens = [];
|
|
78
|
+
for (const token of queryTokens) {
|
|
79
|
+
const terms = [token, ...stemVariants(token)];
|
|
80
|
+
let selected = "none";
|
|
81
|
+
for (const term of terms) {
|
|
82
|
+
const level = scoreLevel(value, term);
|
|
83
|
+
if (level !== "none") {
|
|
84
|
+
selected = level;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (selected === "none")
|
|
89
|
+
continue;
|
|
90
|
+
const score = matchWeight[selected] * fieldWeight(field);
|
|
91
|
+
if (score > bestScore) {
|
|
92
|
+
bestLevel = selected;
|
|
93
|
+
bestScore = score;
|
|
94
|
+
matchedTokens = [token];
|
|
95
|
+
}
|
|
96
|
+
else if (score === bestScore && !matchedTokens.includes(token)) {
|
|
97
|
+
matchedTokens.push(token);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { level: bestLevel, score: bestScore, matchedTokens };
|
|
101
|
+
}
|
|
102
|
+
function tokenMatches(value, token) {
|
|
103
|
+
const normalized = normalizeQuery(value);
|
|
104
|
+
return [token, ...stemVariants(token)].some((term) => normalized.includes(term));
|
|
105
|
+
}
|
|
106
|
+
function matchReason(fields) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
for (const [field, scored] of fields) {
|
|
109
|
+
if (scored.level === "none")
|
|
110
|
+
continue;
|
|
111
|
+
const tokens = scored.matchedTokens
|
|
112
|
+
.slice(0, 4)
|
|
113
|
+
.map((token) => token.slice(0, 20));
|
|
114
|
+
parts.push(`${field}:${scored.level}(${tokens.join(" ")})`);
|
|
115
|
+
}
|
|
116
|
+
return parts.length > 0 ? parts.join("; ") : undefined;
|
|
117
|
+
}
|
|
118
|
+
function checkedSearchOutput(output) {
|
|
119
|
+
const parsed = searchOutputSchema.safeParse(output);
|
|
120
|
+
if (!parsed.success) {
|
|
121
|
+
throw new ForageError("invalid_input", "Search output exceeded its bounds");
|
|
122
|
+
}
|
|
123
|
+
return parsed.data;
|
|
124
|
+
}
|
|
125
|
+
function clusterKey(operation) {
|
|
126
|
+
return [
|
|
127
|
+
operationMethod(operation.path),
|
|
128
|
+
(operation.description ?? "").trim().toLowerCase().replace(/\s+/g, " "),
|
|
129
|
+
operation.fingerprint,
|
|
130
|
+
].join("\u0000");
|
|
131
|
+
}
|
|
132
|
+
function emptyResult(stamp) {
|
|
133
|
+
return checkedSearchOutput({
|
|
134
|
+
results: [],
|
|
135
|
+
total: 0,
|
|
136
|
+
truncated: false,
|
|
137
|
+
topScore: 0,
|
|
138
|
+
margin: 0,
|
|
139
|
+
catalogStamp: stamp,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function scoreCandidate(operation, normalizedQuery, queryTokens, coverageTokens, threshold) {
|
|
143
|
+
const namespace = operationNamespace(operation.path);
|
|
144
|
+
const method = operationMethod(operation.path);
|
|
145
|
+
const description = operation.description ?? "";
|
|
146
|
+
const fields = [operation.path, namespace, method, description];
|
|
147
|
+
const pathScore = scoreField(operation.path, "path", queryTokens);
|
|
148
|
+
const namespaceScore = scoreField(namespace, "namespace", queryTokens);
|
|
149
|
+
const methodScore = scoreField(method, "method", queryTokens);
|
|
150
|
+
const descriptionScore = scoreField(description, "description", queryTokens);
|
|
151
|
+
const matched = coverageTokens.filter((token) => fields.some((field) => tokenMatches(field, token))).length;
|
|
152
|
+
const coverage = matched / coverageTokens.length;
|
|
153
|
+
const phraseMatch = normalizeQuery(`${operation.path} ${description}`).includes(normalizedQuery);
|
|
154
|
+
if (coverage < threshold && !phraseMatch)
|
|
155
|
+
return undefined;
|
|
156
|
+
const baseScore = Math.max(pathScore.score, namespaceScore.score, methodScore.score, descriptionScore.score);
|
|
157
|
+
if (baseScore === 0)
|
|
158
|
+
return undefined;
|
|
159
|
+
let bonus = matched === coverageTokens.length ? 25 : coverage * 10;
|
|
160
|
+
const firstToken = coverageTokens[0];
|
|
161
|
+
if (firstToken && fields.some((field) => tokenMatches(field, firstToken))) {
|
|
162
|
+
bonus += 8;
|
|
163
|
+
}
|
|
164
|
+
if (pathScore.level === "exact" || methodScore.level === "exact")
|
|
165
|
+
bonus += 20;
|
|
166
|
+
const result = {
|
|
167
|
+
path: operation.path,
|
|
168
|
+
score: baseScore + bonus,
|
|
169
|
+
};
|
|
170
|
+
if (description)
|
|
171
|
+
result.description = description;
|
|
172
|
+
const reason = matchReason([
|
|
173
|
+
["method", methodScore],
|
|
174
|
+
["description", descriptionScore],
|
|
175
|
+
["path", pathScore],
|
|
176
|
+
["namespace", namespaceScore],
|
|
177
|
+
]);
|
|
178
|
+
if (reason)
|
|
179
|
+
result.matchReason = reason;
|
|
180
|
+
if (operation.sample)
|
|
181
|
+
result.sample = operation.sample;
|
|
182
|
+
if (operation.capabilityNotes)
|
|
183
|
+
result.capabilityNotes = operation.capabilityNotes;
|
|
184
|
+
return {
|
|
185
|
+
result,
|
|
186
|
+
cluster: clusterKey(operation),
|
|
187
|
+
pathScore: pathScore.score,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function mergeCandidateClusters(candidates) {
|
|
191
|
+
const grouped = new Map();
|
|
192
|
+
for (const candidate of candidates) {
|
|
193
|
+
const group = grouped.get(candidate.cluster) ?? [];
|
|
194
|
+
group.push(candidate);
|
|
195
|
+
grouped.set(candidate.cluster, group);
|
|
196
|
+
}
|
|
197
|
+
const merged = [];
|
|
198
|
+
for (const group of grouped.values()) {
|
|
199
|
+
const sorted = [...group].sort((left, right) => right.pathScore - left.pathScore ||
|
|
200
|
+
right.result.score - left.result.score ||
|
|
201
|
+
compareCodeUnits(left.result.path, right.result.path));
|
|
202
|
+
const survivor = sorted[0];
|
|
203
|
+
if (!survivor)
|
|
204
|
+
continue;
|
|
205
|
+
const peers = [];
|
|
206
|
+
const peerCandidates = sorted
|
|
207
|
+
.slice(1)
|
|
208
|
+
.sort((left, right) => compareCodeUnits(left.result.path, right.result.path));
|
|
209
|
+
for (const candidate of peerCandidates) {
|
|
210
|
+
const peer = {
|
|
211
|
+
path: candidate.result.path,
|
|
212
|
+
};
|
|
213
|
+
if (candidate.result.capabilityNotes) {
|
|
214
|
+
peer.capabilityNotes = candidate.result.capabilityNotes;
|
|
215
|
+
}
|
|
216
|
+
peers.push(peer);
|
|
217
|
+
}
|
|
218
|
+
const mergedResult = {
|
|
219
|
+
...survivor.result,
|
|
220
|
+
score: Math.max(...sorted.map((candidate) => candidate.result.score)),
|
|
221
|
+
};
|
|
222
|
+
if (peers.length > 0)
|
|
223
|
+
mergedResult.peers = peers;
|
|
224
|
+
merged.push(mergedResult);
|
|
225
|
+
}
|
|
226
|
+
return merged.sort((left, right) => right.score - left.score || compareCodeUnits(left.path, right.path));
|
|
227
|
+
}
|
|
228
|
+
function checkedRankedOutput(merged, resultLimit, stamp) {
|
|
229
|
+
const bounded = merged.slice(0, resultLimit);
|
|
230
|
+
const top = bounded[0];
|
|
231
|
+
const second = bounded[1];
|
|
232
|
+
return checkedSearchOutput({
|
|
233
|
+
results: bounded,
|
|
234
|
+
total: merged.length,
|
|
235
|
+
truncated: merged.length > resultLimit,
|
|
236
|
+
topScore: top?.score ?? 0,
|
|
237
|
+
margin: top && second ? top.score - second.score : 0,
|
|
238
|
+
catalogStamp: stamp,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** Pure deterministic ranking over one immutable operation snapshot. */
|
|
242
|
+
export function searchCatalog(operations, query, maxResults = 50, stamp) {
|
|
243
|
+
const resultLimit = Math.min(50, Math.max(1, Math.floor(maxResults)));
|
|
244
|
+
const normalizedQuery = normalizeQuery(query);
|
|
245
|
+
const queryTokens = tokenize(normalizedQuery);
|
|
246
|
+
const coverageTokens = queryTokens.filter((token) => !isStopword(token));
|
|
247
|
+
if (coverageTokens.length === 0)
|
|
248
|
+
return emptyResult(stamp);
|
|
249
|
+
const threshold = coverageTokens.length <= 2 ? 1 : 0.6;
|
|
250
|
+
const candidates = [];
|
|
251
|
+
for (const operation of operations) {
|
|
252
|
+
const candidate = scoreCandidate(operation, normalizedQuery, queryTokens, coverageTokens, threshold);
|
|
253
|
+
if (candidate)
|
|
254
|
+
candidates.push(candidate);
|
|
255
|
+
}
|
|
256
|
+
return checkedRankedOutput(mergeCandidateClusters(candidates), resultLimit, stamp);
|
|
257
|
+
}
|
|
258
|
+
export { catalogStamp };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
|
|
2
|
+
|
|
3
|
+
**@fungi.computer/forage**
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# @fungi.computer/forage
|
|
8
|
+
|
|
9
|
+
## Modules
|
|
10
|
+
|
|
11
|
+
| Module | Description |
|
|
12
|
+
| ------------------------------------------- | ----------- |
|
|
13
|
+
| [index](index.md) | - |
|
|
14
|
+
| [json-schema-subset](json-schema-subset.md) | - |
|
|
15
|
+
| [openapi](openapi.md) | - |
|