lynkr 9.7.3 → 9.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -25
- package/bin/cli.js +16 -1
- package/bin/lynkr-init.js +44 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +23 -2
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/build-eval-set.js +256 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/validate-difficulty-classifier.js +123 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +467 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +932 -47
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +88 -15
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +931 -90
- package/src/routing/intent-score.js +441 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +86 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Mine empirically-labeled difficulty anchors from public router datasets.
|
|
4
|
+
*
|
|
5
|
+
* Sources (fetched via the HF datasets-server JSON API, no deps):
|
|
6
|
+
* - RouteWorks/RouterArena (full split): per-query empirical difficulty
|
|
7
|
+
* bands — easy (≥20/42 models correct), medium (5–19/42), hard (≤4/42).
|
|
8
|
+
* - routellm/gpt4_dataset: per-prompt mixtral_score 1–5 (GPT-4-judged
|
|
9
|
+
* quality of the weak model's answer).
|
|
10
|
+
*
|
|
11
|
+
* Label recipe (config B: local → GLM → Claude):
|
|
12
|
+
* - substantive (45, MEDIUM/ollama): RouterArena easy + mixtral_score 5
|
|
13
|
+
* - heavyweight (68, COMPLEX/GLM): RouterArena medium + mixtral_score ≤2
|
|
14
|
+
* - frontier (85, REASONING/Claude): RouterArena hard + hand examples
|
|
15
|
+
* - trivial stays hand-curated — benchmarks have no conversational prompts.
|
|
16
|
+
*
|
|
17
|
+
* Output: data/difficulty-anchors.json (gitignored, wins over config/) and
|
|
18
|
+
* data/difficulty-anchors.provenance.json. The bundled 13-anchor default in
|
|
19
|
+
* config/ is untouched.
|
|
20
|
+
*
|
|
21
|
+
* Requires local Ollama with nomic-embed-text (for diversity selection).
|
|
22
|
+
* Usage: node scripts/mine-difficulty-anchors.js [--dry-run]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
|
|
28
|
+
const DS_SERVER = "https://datasets-server.huggingface.co/rows";
|
|
29
|
+
const OLLAMA_EMBED = process.env.OLLAMA_EMBEDDINGS_ENDPOINT || "http://localhost:11434/api/embeddings";
|
|
30
|
+
const EMBED_MODEL = process.env.OLLAMA_EMBEDDINGS_MODEL || "nomic-embed-text";
|
|
31
|
+
|
|
32
|
+
const OUT_ANCHORS = path.join(__dirname, "../data/difficulty-anchors.json");
|
|
33
|
+
const OUT_PROVENANCE = path.join(__dirname, "../data/difficulty-anchors.provenance.json");
|
|
34
|
+
const CONFIG_ANCHORS = path.join(__dirname, "../config/difficulty-anchors.json");
|
|
35
|
+
|
|
36
|
+
// Per-class targets after diversity selection (mined only, hand anchors extra)
|
|
37
|
+
const TARGETS = { substantive: 130, heavyweight: 130, frontier: 100 };
|
|
38
|
+
|
|
39
|
+
// Hand-written frontier examples: engineering-flavored deep-reasoning asks.
|
|
40
|
+
// RouterArena's hard band skews competition-academic; real Lynkr frontier
|
|
41
|
+
// traffic looks like these. Both kinds go in.
|
|
42
|
+
const HAND_FRONTIER = [
|
|
43
|
+
"Prove the correctness of this lock-free queue implementation and identify any ABA hazards",
|
|
44
|
+
"Design a novel conflict-free replicated data type for collaborative rich-text editing and argue its convergence from first principles",
|
|
45
|
+
"Derive the optimal cache eviction policy for this access distribution and prove its competitive ratio",
|
|
46
|
+
"Diagnose this heisenbug: a race that only reproduces under load across three services, given these interleaved logs",
|
|
47
|
+
"Design a Byzantine-fault-tolerant consensus protocol variant that tolerates f faults with 2f+1 replicas by weakening liveness, and analyze exactly which guarantees are lost",
|
|
48
|
+
"Formally verify that this state machine can never deadlock, or produce a counterexample trace",
|
|
49
|
+
"Given these profiler traces, determine whether the tail latency is queueing-theoretic or GC-driven, and prove which by constructing a discriminating experiment",
|
|
50
|
+
"Reason through the security of this key-rotation scheme under an adversary who can observe but not modify traffic, and find the weakest assumption it relies on",
|
|
51
|
+
"Work out the exact memory ordering constraints this concurrent hashmap needs on ARM, and justify each barrier from the C++ memory model",
|
|
52
|
+
"Given this failing distributed transaction trace, determine whether the anomaly is write skew or lost update, and design the minimal isolation-level change that eliminates it",
|
|
53
|
+
"Derive the amortized complexity of this self-adjusting tree under this adversarial access pattern, and prove the bound is tight",
|
|
54
|
+
"Analyze whether this migration can be run online without violating any invariant the application relies on, enumerating the interleavings that could corrupt state",
|
|
55
|
+
"Reverse-engineer why this JIT deoptimizes on this hot path and design a fix that preserves the semantics, with reasoning about hidden class transitions",
|
|
56
|
+
"Prove this rate limiter is fair under concurrent refill, or construct the starvation schedule that breaks it",
|
|
57
|
+
"Design an exactly-once delivery layer on top of an at-least-once queue without idempotency keys, or prove it is impossible under these constraints",
|
|
58
|
+
"Given these heap dumps over time, reason to the retention path causing the leak and rule out the two most plausible alternative explanations",
|
|
59
|
+
"Reconcile these conflicting benchmark results by identifying the confound in the experimental setup, and design the experiment that isolates it",
|
|
60
|
+
"Determine the weakest consistency model under which this caching layer is still linearizable from the client's perspective, with proof",
|
|
61
|
+
"Plan a zero-downtime re-sharding of this 2TB database under sustained writes, reasoning through every failure mode and its recovery path",
|
|
62
|
+
"Explain step by step why this floating-point summation loses precision at scale and derive the compensated algorithm that bounds the error",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
66
|
+
|
|
67
|
+
async function fetchRows(dataset, config, split, offset, length) {
|
|
68
|
+
const url = `${DS_SERVER}?dataset=${encodeURIComponent(dataset)}&config=${config}&split=${split}&offset=${offset}&length=${length}`;
|
|
69
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(url);
|
|
72
|
+
if (res.status === 429) { await sleep(2500 * (attempt + 1)); continue; }
|
|
73
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
74
|
+
const json = await res.json();
|
|
75
|
+
return { rows: json.rows?.map(r => r.row) || [], total: json.num_rows_total ?? null };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (attempt === 5) {
|
|
78
|
+
console.warn(` page offset=${offset} failed after retries (${err.message}) — skipping`);
|
|
79
|
+
return { rows: [], total: null, failed: true };
|
|
80
|
+
}
|
|
81
|
+
await sleep(1500 * (attempt + 1));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { rows: [], total: null, failed: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- candidate filters -------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function cleanCandidate(text) {
|
|
90
|
+
if (typeof text !== "string") return null;
|
|
91
|
+
const t = text.replace(/\s+/g, " ").trim();
|
|
92
|
+
if (t.length < 15 || t.length > 400) return null;
|
|
93
|
+
// Cloze / MCQ scaffolding reads nothing like a typed ask
|
|
94
|
+
if (/\(\s*\)/.test(t) || /_{3,}/.test(t)) return null;
|
|
95
|
+
if (/\b[A-D]\)\s/.test(t) || /which of the following/i.test(t)) return null;
|
|
96
|
+
// NLI/benchmark scaffolding (gpt4_dataset carries FLAN-style tasks)
|
|
97
|
+
if (/\b(premise|hypothesis)\b/i.test(t) && /\b(entail|inference|contradict)\b/i.test(t)) return null;
|
|
98
|
+
if (/natural language inference|answer with (yes|no)|options: -/i.test(t)) return null;
|
|
99
|
+
// Anonymization tokens (gpt4_dataset) and template junk
|
|
100
|
+
if (/NAME_\d|\{\{.*\}\}/.test(t)) return null;
|
|
101
|
+
// Mostly-English only: embeddings + our traffic are English
|
|
102
|
+
const nonAscii = (t.match(/[^\x20-\x7E]/g) || []).length;
|
|
103
|
+
if (nonAscii > t.length * 0.1) return null;
|
|
104
|
+
return t;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Quiz-bowl clue style: leading demonstrative riddles and pronunciation
|
|
108
|
+
// guides. "Hard" trivia measures obscure recall, not reasoning depth — it
|
|
109
|
+
// would anchor the frontier class to the wrong concept entirely.
|
|
110
|
+
function isQuizbowlStyle(t) {
|
|
111
|
+
return /^(this |one |a \d{4} (book|work|paper)|fictional |in one (work|section))/i.test(t) ||
|
|
112
|
+
/\([A-Za-z]+-[A-Z][A-Za-z-]+\)/.test(t);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const TECH_DOMAIN_RE = /computer science|technology|engineering|mathematic|science/i;
|
|
116
|
+
// SuperGLUE tasks are benchmark scaffolding, not natural asks.
|
|
117
|
+
const DATASET_EXCLUDE_RE = /superglue|cloze|entailment/i;
|
|
118
|
+
|
|
119
|
+
// --- diversity selection (greedy k-center / max-min) -------------------------
|
|
120
|
+
|
|
121
|
+
async function embed(text) {
|
|
122
|
+
const res = await fetch(OLLAMA_EMBED, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: { "content-type": "application/json" },
|
|
125
|
+
body: JSON.stringify({ model: EMBED_MODEL, prompt: text }),
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) throw new Error(`embed HTTP ${res.status}`);
|
|
128
|
+
const json = await res.json();
|
|
129
|
+
return json.embedding;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cosine(a, b) {
|
|
133
|
+
let dot = 0, na = 0, nb = 0;
|
|
134
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
135
|
+
const d = Math.sqrt(na) * Math.sqrt(nb);
|
|
136
|
+
return d > 0 ? dot / d : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function diversitySelect(candidates, target, label) {
|
|
140
|
+
if (candidates.length <= target) return candidates.map(c => c.text);
|
|
141
|
+
process.stdout.write(` embedding ${candidates.length} ${label} candidates...`);
|
|
142
|
+
const embedded = [];
|
|
143
|
+
for (const c of candidates) {
|
|
144
|
+
try {
|
|
145
|
+
embedded.push({ ...c, vec: await embed(c.text) });
|
|
146
|
+
} catch { /* skip failures */ }
|
|
147
|
+
if (embedded.length % 200 === 0) process.stdout.write(".");
|
|
148
|
+
}
|
|
149
|
+
console.log(` ${embedded.length} embedded`);
|
|
150
|
+
|
|
151
|
+
// Greedy max-min: always keep the candidate farthest from the selected set.
|
|
152
|
+
const selected = [embedded[0]];
|
|
153
|
+
const minSim = new Array(embedded.length).fill(-1).map((_, i) => cosine(embedded[i].vec, embedded[0].vec));
|
|
154
|
+
while (selected.length < target) {
|
|
155
|
+
let bestIdx = -1, bestVal = Infinity;
|
|
156
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
157
|
+
if (minSim[i] === Infinity) continue; // already selected
|
|
158
|
+
if (minSim[i] < bestVal) { bestVal = minSim[i]; bestIdx = i; }
|
|
159
|
+
}
|
|
160
|
+
if (bestIdx === -1) break;
|
|
161
|
+
const pick = embedded[bestIdx];
|
|
162
|
+
selected.push(pick);
|
|
163
|
+
minSim[bestIdx] = Infinity;
|
|
164
|
+
for (let i = 0; i < embedded.length; i++) {
|
|
165
|
+
if (minSim[i] === Infinity) continue;
|
|
166
|
+
const s = cosine(embedded[i].vec, pick.vec);
|
|
167
|
+
if (s > minSim[i]) minSim[i] = s;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return selected.map(s => s.text);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- main --------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async function main() {
|
|
176
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
177
|
+
const pools = { substantive: [], heavyweight: [], frontier: [] };
|
|
178
|
+
const provenance = {};
|
|
179
|
+
|
|
180
|
+
// RouterArena: full split, paginate everything (~8.4k rows)
|
|
181
|
+
console.log("Fetching RouterArena (full split)...");
|
|
182
|
+
let raCount = 0;
|
|
183
|
+
let raTotal = null;
|
|
184
|
+
for (let offset = 0; raTotal === null || offset < raTotal; offset += 100) {
|
|
185
|
+
const { rows, total } = await fetchRows("RouteWorks/RouterArena", "default", "full", offset, 100);
|
|
186
|
+
if (total !== null) raTotal = total;
|
|
187
|
+
if (rows.length === 0 && raTotal === null) break;
|
|
188
|
+
raCount += rows.length;
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
if (row.Context && String(row.Context).trim()) continue; // passage-dependent
|
|
191
|
+
if (Array.isArray(row.Options) && row.Options.length > 0) continue; // MCQ
|
|
192
|
+
if (DATASET_EXCLUDE_RE.test(String(row["Dataset name"] || ""))) continue;
|
|
193
|
+
const text = cleanCandidate(row.Question);
|
|
194
|
+
if (!text || isQuizbowlStyle(text)) continue;
|
|
195
|
+
const technical = TECH_DOMAIN_RE.test(String(row.Domain || ""));
|
|
196
|
+
const diff = String(row.Difficulty || "").toLowerCase();
|
|
197
|
+
// hard→frontier and medium→heavyweight only from technical domains:
|
|
198
|
+
// non-technical "hard" is dominated by obscure-recall trivia, which
|
|
199
|
+
// anchors the expensive tiers to the wrong concept.
|
|
200
|
+
let cls = null;
|
|
201
|
+
if (diff === "easy") cls = "substantive";
|
|
202
|
+
else if (diff === "medium" && technical) cls = "heavyweight";
|
|
203
|
+
else if (diff === "hard" && technical) cls = "frontier";
|
|
204
|
+
if (!cls) continue;
|
|
205
|
+
pools[cls].push({ text, source: "RouterArena", label: `${row.Difficulty}/${row["Dataset name"]}`, technical });
|
|
206
|
+
}
|
|
207
|
+
if (offset % 2000 === 0 && offset > 0) console.log(` ...${raCount}/${raTotal ?? "?"} rows scanned`);
|
|
208
|
+
await sleep(250); // stay friendly to the API
|
|
209
|
+
}
|
|
210
|
+
console.log(`RouterArena: ${raCount} rows → pools: sub=${pools.substantive.length} heavy=${pools.heavyweight.length} frontier=${pools.frontier.length}`);
|
|
211
|
+
|
|
212
|
+
// gpt4_dataset: sample pages spread across the 109k-row train split
|
|
213
|
+
console.log("Sampling routellm/gpt4_dataset...");
|
|
214
|
+
const PAGES = 60;
|
|
215
|
+
const SPAN = 109000;
|
|
216
|
+
for (let p = 0; p < PAGES; p++) {
|
|
217
|
+
const offset = Math.floor((p * SPAN) / PAGES);
|
|
218
|
+
const { rows } = await fetchRows("routellm/gpt4_dataset", "default", "train", offset, 100);
|
|
219
|
+
for (const row of rows) {
|
|
220
|
+
const text = cleanCandidate(row.prompt);
|
|
221
|
+
if (!text) continue;
|
|
222
|
+
const score = Number(row.mixtral_score);
|
|
223
|
+
const technical = /code|function|script|debug|compile|regex|sql|api|server|deploy|git|test/i.test(text);
|
|
224
|
+
if (score === 5) {
|
|
225
|
+
pools.substantive.push({ text, source: "gpt4_dataset", label: `mixtral_score=${score}`, technical });
|
|
226
|
+
} else if (score <= 2 && score >= 1 && text.length >= 60) {
|
|
227
|
+
// ≥60 chars: low judge scores on one-liners are label noise
|
|
228
|
+
// ("What is the capital of Massachusetts?" scored 1), not difficulty.
|
|
229
|
+
pools.heavyweight.push({ text, source: "gpt4_dataset", label: `mixtral_score=${score}`, technical });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
await sleep(250);
|
|
233
|
+
}
|
|
234
|
+
console.log(`After gpt4_dataset: sub=${pools.substantive.length} heavy=${pools.heavyweight.length} frontier=${pools.frontier.length}`);
|
|
235
|
+
|
|
236
|
+
// Bias ~60% technical where the pool allows: sort technical-first, then
|
|
237
|
+
// interleave so diversity selection sees both kinds.
|
|
238
|
+
for (const cls of Object.keys(pools)) {
|
|
239
|
+
const tech = pools[cls].filter(c => c.technical);
|
|
240
|
+
const gen = pools[cls].filter(c => !c.technical);
|
|
241
|
+
const mixed = [];
|
|
242
|
+
let ti = 0, gi = 0;
|
|
243
|
+
while (ti < tech.length || gi < gen.length) {
|
|
244
|
+
// 3 technical : 2 general cadence ≈ 60/40
|
|
245
|
+
for (let k = 0; k < 3 && ti < tech.length; k++) mixed.push(tech[ti++]);
|
|
246
|
+
for (let k = 0; k < 2 && gi < gen.length; k++) mixed.push(gen[gi++]);
|
|
247
|
+
}
|
|
248
|
+
pools[cls] = mixed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (dryRun) {
|
|
252
|
+
for (const cls of Object.keys(pools)) {
|
|
253
|
+
console.log(`\n=== ${cls} (${pools[cls].length} candidates), first 5:`);
|
|
254
|
+
for (const c of pools[cls].slice(0, 5)) console.log(` [${c.source}/${c.label}] ${c.text.slice(0, 100)}`);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Diversity-select down to targets
|
|
260
|
+
const selected = {};
|
|
261
|
+
for (const [cls, target] of Object.entries(TARGETS)) {
|
|
262
|
+
selected[cls] = await diversitySelect(pools[cls], target, cls);
|
|
263
|
+
for (const text of selected[cls]) {
|
|
264
|
+
const cand = pools[cls].find(c => c.text === text);
|
|
265
|
+
provenance[text] = { class: cls, source: cand?.source, label: cand?.label };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Merge with the bundled hand anchors (they always survive)
|
|
270
|
+
const hand = JSON.parse(fs.readFileSync(CONFIG_ANCHORS, "utf8"));
|
|
271
|
+
const out = {
|
|
272
|
+
_comment: `Mined ${new Date().toISOString().slice(0, 10)} by scripts/mine-difficulty-anchors.js from RouteWorks/RouterArena (empirical difficulty bands) and routellm/gpt4_dataset (mixtral_score). Hand anchors from config/difficulty-anchors.json retained. Local only (data/ is gitignored and excluded from the npm tarball).`,
|
|
273
|
+
trivial: hand.trivial,
|
|
274
|
+
substantive: [...hand.substantive, ...selected.substantive],
|
|
275
|
+
heavyweight: [...hand.heavyweight, ...selected.heavyweight],
|
|
276
|
+
frontier: [...HAND_FRONTIER, ...selected.frontier],
|
|
277
|
+
};
|
|
278
|
+
for (const t of HAND_FRONTIER) provenance[t] = { class: "frontier", source: "hand", label: "engineering-frontier" };
|
|
279
|
+
|
|
280
|
+
fs.writeFileSync(OUT_ANCHORS, JSON.stringify(out, null, 2));
|
|
281
|
+
fs.writeFileSync(OUT_PROVENANCE, JSON.stringify(provenance, null, 2));
|
|
282
|
+
console.log(`\nWrote ${OUT_ANCHORS}:`);
|
|
283
|
+
for (const cls of ["trivial", "substantive", "heavyweight", "frontier"]) {
|
|
284
|
+
console.log(` ${cls}: ${out[cls].length} anchors`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
main().catch(err => { console.error(err); process.exit(1); });
|