lynkr 9.7.3 → 9.9.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 (74) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/api/middleware/loop-guard.js +87 -0
  15. package/src/api/middleware/request-logging.js +5 -64
  16. package/src/api/middleware/session.js +0 -0
  17. package/src/api/openai-router.js +120 -101
  18. package/src/api/providers-handler.js +27 -2
  19. package/src/api/router.js +450 -125
  20. package/src/budget/index.js +2 -19
  21. package/src/cache/semantic.js +9 -0
  22. package/src/clients/databricks.js +455 -142
  23. package/src/clients/gpt-utils.js +11 -105
  24. package/src/clients/openai-format.js +10 -3
  25. package/src/clients/openrouter-utils.js +49 -24
  26. package/src/clients/prompt-cache-injection.js +1 -0
  27. package/src/clients/provider-capabilities.js +1 -1
  28. package/src/clients/responses-format.js +34 -3
  29. package/src/clients/routing.js +15 -0
  30. package/src/config/index.js +36 -2
  31. package/src/context/gcf.js +275 -0
  32. package/src/context/tool-result-compressor.js +51 -9
  33. package/src/dashboard/api.js +1 -0
  34. package/src/logger/index.js +14 -1
  35. package/src/memory/search.js +12 -40
  36. package/src/memory/tools.js +3 -24
  37. package/src/orchestrator/bypass.js +4 -2
  38. package/src/orchestrator/index.js +120 -85
  39. package/src/routing/affinity-store.js +194 -0
  40. package/src/routing/agentic-detector.js +36 -6
  41. package/src/routing/bandit.js +25 -6
  42. package/src/routing/calibration.js +212 -0
  43. package/src/routing/client-profiles.js +292 -0
  44. package/src/routing/complexity-analyzer.js +48 -11
  45. package/src/routing/deescalator.js +148 -0
  46. package/src/routing/degradation.js +109 -0
  47. package/src/routing/feedback.js +157 -0
  48. package/src/routing/index.js +897 -87
  49. package/src/routing/intent-score.js +339 -0
  50. package/src/routing/interaction.js +3 -0
  51. package/src/routing/knn-router.js +70 -21
  52. package/src/routing/model-registry.js +28 -7
  53. package/src/routing/model-tiers.js +25 -2
  54. package/src/routing/reward-pipeline.js +68 -2
  55. package/src/routing/risk-analyzer.js +30 -1
  56. package/src/routing/risk-classifier.js +6 -2
  57. package/src/routing/session-affinity.js +162 -34
  58. package/src/routing/telemetry.js +286 -13
  59. package/src/routing/verifier.js +267 -0
  60. package/src/server.js +66 -21
  61. package/src/sessions/cleanup.js +17 -0
  62. package/src/tools/index.js +1 -15
  63. package/src/tools/smart-selection.js +10 -0
  64. package/src/tools/web-client.js +3 -3
  65. package/.eslintrc.cjs +0 -12
  66. package/benchmark-configs/litellm_config.yaml +0 -86
  67. package/benchmark-configs/lynkr.env +0 -48
  68. package/benchmark-configs/portkey-config.json +0 -60
  69. package/benchmark-configs/portkey-docker.sh +0 -23
  70. package/benchmark-tier-routing.js +0 -449
  71. package/funding.json +0 -110
  72. package/src/api/middleware/validation.js +0 -261
  73. package/src/routing/drift-monitor.js +0 -113
  74. package/src/workers/helpers.js +0 -185
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * LLM Audit Log Reader
5
+ *
6
+ * Utility to read and reconstruct audit log entries from deduplicated logs.
7
+ * Resolves hash references from the dictionary file and outputs full content.
8
+ *
9
+ * Usage:
10
+ * node scripts/audit-log-reader.js [options]
11
+ *
12
+ * Options:
13
+ * --log-file <path> Path to audit log file (default: logs/llm-audit.log)
14
+ * --dict-file <path> Path to dictionary file (default: logs/llm-audit-dictionary.jsonl)
15
+ * --full Output full restored entries (resolve all references)
16
+ * --filter <type=value> Filter by field (e.g., type=llm_request, provider=anthropic)
17
+ * --correlation-id <id> Filter by correlation ID
18
+ * --last <n> Show only last N entries
19
+ * --stats Show deduplication statistics
20
+ * --verify Verify all references can be resolved
21
+ * --help Show this help message
22
+ *
23
+ * Examples:
24
+ * # Show all entries with full content
25
+ * node scripts/audit-log-reader.js --full
26
+ *
27
+ * # Show only requests
28
+ * node scripts/audit-log-reader.js --filter type=llm_request
29
+ *
30
+ * # Show last 5 entries
31
+ * node scripts/audit-log-reader.js --last 5 --full
32
+ *
33
+ * # Show deduplication statistics
34
+ * node scripts/audit-log-reader.js --stats
35
+ *
36
+ * # Verify all references resolve
37
+ * node scripts/audit-log-reader.js --verify
38
+ */
39
+
40
+ const fs = require("fs");
41
+ const path = require("path");
42
+ const readline = require("readline");
43
+ const { ContentDeduplicator } = require("../src/logger/deduplicator");
44
+
45
+ // Default file paths
46
+ const DEFAULT_LOG_FILE = path.join(process.cwd(), "logs", "llm-audit.log");
47
+ const DEFAULT_DICT_FILE = path.join(process.cwd(), "logs", "llm-audit-dictionary.jsonl");
48
+
49
+ /**
50
+ * Parse command line arguments
51
+ */
52
+ function parseArgs() {
53
+ const args = process.argv.slice(2);
54
+ const options = {
55
+ logFile: DEFAULT_LOG_FILE,
56
+ dictFile: DEFAULT_DICT_FILE,
57
+ full: false,
58
+ filter: null,
59
+ correlationId: null,
60
+ last: null,
61
+ stats: false,
62
+ verify: false,
63
+ help: false,
64
+ };
65
+
66
+ for (let i = 0; i < args.length; i++) {
67
+ const arg = args[i];
68
+ switch (arg) {
69
+ case "--log-file":
70
+ options.logFile = args[++i];
71
+ break;
72
+ case "--dict-file":
73
+ options.dictFile = args[++i];
74
+ break;
75
+ case "--full":
76
+ options.full = true;
77
+ break;
78
+ case "--filter":
79
+ const filterArg = args[++i];
80
+ const [key, value] = filterArg.split("=");
81
+ options.filter = { key, value };
82
+ break;
83
+ case "--correlation-id":
84
+ options.correlationId = args[++i];
85
+ break;
86
+ case "--last":
87
+ options.last = Number.parseInt(args[++i], 10);
88
+ break;
89
+ case "--stats":
90
+ options.stats = true;
91
+ break;
92
+ case "--verify":
93
+ options.verify = true;
94
+ break;
95
+ case "--help":
96
+ options.help = true;
97
+ break;
98
+ default:
99
+ console.error(`Unknown option: ${arg}`);
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ return options;
105
+ }
106
+
107
+ /**
108
+ * Show help message
109
+ */
110
+ function showHelp() {
111
+ const helpText = `
112
+ LLM Audit Log Reader
113
+
114
+ Usage: node scripts/audit-log-reader.js [options]
115
+
116
+ Options:
117
+ --log-file <path> Path to audit log file (default: logs/llm-audit.log)
118
+ --dict-file <path> Path to dictionary file (default: logs/llm-audit-dictionary.jsonl)
119
+ --full Output full restored entries (resolve all references)
120
+ --filter <type=value> Filter by field (e.g., type=llm_request, provider=anthropic)
121
+ --correlation-id <id> Filter by correlation ID
122
+ --last <n> Show only last N entries
123
+ --stats Show deduplication statistics
124
+ --verify Verify all references can be resolved
125
+ --help Show this help message
126
+
127
+ Examples:
128
+ # Show all entries with full content
129
+ node scripts/audit-log-reader.js --full
130
+
131
+ # Show only requests
132
+ node scripts/audit-log-reader.js --filter type=llm_request
133
+
134
+ # Show last 5 entries
135
+ node scripts/audit-log-reader.js --last 5 --full
136
+
137
+ # Show deduplication statistics
138
+ node scripts/audit-log-reader.js --stats
139
+
140
+ # Verify all references resolve
141
+ node scripts/audit-log-reader.js --verify
142
+ `;
143
+ console.log(helpText);
144
+ }
145
+
146
+ /**
147
+ * Read and process log entries
148
+ */
149
+ async function readLogEntries(options) {
150
+ const { logFile, dictFile, full, filter, correlationId, last } = options;
151
+
152
+ // Check if log file exists
153
+ if (!fs.existsSync(logFile)) {
154
+ console.error(`Log file not found: ${logFile}`);
155
+ process.exit(1);
156
+ }
157
+
158
+ // Initialize deduplicator if needed
159
+ let deduplicator = null;
160
+ if (full && fs.existsSync(dictFile)) {
161
+ deduplicator = new ContentDeduplicator(dictFile);
162
+ }
163
+
164
+ const entries = [];
165
+
166
+ // Read log file line by line
167
+ const fileStream = fs.createReadStream(logFile);
168
+ const rl = readline.createInterface({
169
+ input: fileStream,
170
+ crlfDelay: Infinity,
171
+ });
172
+
173
+ for await (const line of rl) {
174
+ if (!line.trim()) continue;
175
+
176
+ try {
177
+ let entry = JSON.parse(line);
178
+
179
+ // Apply filters
180
+ if (filter && entry[filter.key] !== filter.value) {
181
+ continue;
182
+ }
183
+ if (correlationId && entry.correlationId !== correlationId) {
184
+ continue;
185
+ }
186
+
187
+ // Restore full content if requested
188
+ if (full && deduplicator) {
189
+ entry = deduplicator.restoreEntry(entry);
190
+ }
191
+
192
+ entries.push(entry);
193
+ } catch (err) {
194
+ console.error("Malformed log entry:", err.message);
195
+ }
196
+ }
197
+
198
+ // Apply last N filter
199
+ const output = last ? entries.slice(-last) : entries;
200
+
201
+ // Output as JSONL
202
+ for (const entry of output) {
203
+ console.log(JSON.stringify(entry, null, 2));
204
+ }
205
+
206
+ return entries.length;
207
+ }
208
+
209
+ /**
210
+ * Show deduplication statistics
211
+ */
212
+ async function showStats(options) {
213
+ const { logFile, dictFile } = options;
214
+
215
+ if (!fs.existsSync(logFile)) {
216
+ console.error(`Log file not found: ${logFile}`);
217
+ process.exit(1);
218
+ }
219
+
220
+ if (!fs.existsSync(dictFile)) {
221
+ console.log("No dictionary file found. Deduplication may not be enabled.");
222
+ return;
223
+ }
224
+
225
+ // Get file sizes
226
+ const logStats = fs.statSync(logFile);
227
+ const dictStats = fs.statSync(dictFile);
228
+
229
+ console.log("\n=== LLM Audit Log Deduplication Statistics ===\n");
230
+ console.log(`Log file: ${logFile}`);
231
+ console.log(` Size: ${formatBytes(logStats.size)}`);
232
+ console.log(` Lines: ${await countLines(logFile)}`);
233
+ console.log();
234
+ console.log(`Dictionary file: ${dictFile}`);
235
+ console.log(` Size: ${formatBytes(dictStats.size)}`);
236
+ console.log(` Entries: ${await countLines(dictFile)}`);
237
+ console.log();
238
+ console.log(`Total size: ${formatBytes(logStats.size + dictStats.size)}`);
239
+ console.log();
240
+
241
+ // Count reference occurrences in log
242
+ const refCount = await countReferences(logFile);
243
+ console.log(`Reference objects in log: ${refCount}`);
244
+ console.log(`Estimated space saved: ~${formatBytes(refCount * 2000)} (assuming ~2KB per deduplicated field)`);
245
+ console.log();
246
+ }
247
+
248
+ /**
249
+ * Verify all references can be resolved
250
+ */
251
+ async function verifyReferences(options) {
252
+ const { logFile, dictFile } = options;
253
+
254
+ if (!fs.existsSync(logFile)) {
255
+ console.error(`Log file not found: ${logFile}`);
256
+ process.exit(1);
257
+ }
258
+
259
+ if (!fs.existsSync(dictFile)) {
260
+ console.log("No dictionary file found. Nothing to verify.");
261
+ return;
262
+ }
263
+
264
+ const deduplicator = new ContentDeduplicator(dictFile);
265
+ const fileStream = fs.createReadStream(logFile);
266
+ const rl = readline.createInterface({
267
+ input: fileStream,
268
+ crlfDelay: Infinity,
269
+ });
270
+
271
+ let totalRefs = 0;
272
+ let unresolvedRefs = 0;
273
+ const unresolvedHashes = new Set();
274
+
275
+ console.log("Verifying references...\n");
276
+
277
+ for await (const line of rl) {
278
+ if (!line.trim()) continue;
279
+
280
+ try {
281
+ const entry = JSON.parse(line);
282
+
283
+ // Check all fields for references
284
+ for (const [key, value] of Object.entries(entry)) {
285
+ if (typeof value === "object" && value !== null && value.$ref) {
286
+ totalRefs++;
287
+ const content = deduplicator.getContent(value.$ref);
288
+ if (content === null) {
289
+ unresolvedRefs++;
290
+ unresolvedHashes.add(value.$ref);
291
+ console.error(`✗ Unresolved reference: ${value.$ref} in field "${key}"`);
292
+ }
293
+ }
294
+ }
295
+ } catch (err) {
296
+ console.error("Malformed log entry:", err.message);
297
+ }
298
+ }
299
+
300
+ console.log("\n=== Verification Results ===\n");
301
+ console.log(`Total references: ${totalRefs}`);
302
+ console.log(`Unresolved references: ${unresolvedRefs}`);
303
+ console.log(`Unique unresolved hashes: ${unresolvedHashes.size}`);
304
+
305
+ if (unresolvedRefs === 0) {
306
+ console.log("\n✓ All references resolved successfully!");
307
+ } else {
308
+ console.log("\n✗ Some references could not be resolved. Dictionary may be incomplete.");
309
+ process.exit(1);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Helper: Format bytes to human-readable string
315
+ */
316
+ function formatBytes(bytes) {
317
+ if (bytes < 1024) return `${bytes} B`;
318
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
319
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
320
+ }
321
+
322
+ /**
323
+ * Helper: Count lines in a file
324
+ */
325
+ async function countLines(filePath) {
326
+ const fileStream = fs.createReadStream(filePath);
327
+ const rl = readline.createInterface({
328
+ input: fileStream,
329
+ crlfDelay: Infinity,
330
+ });
331
+
332
+ let count = 0;
333
+ for await (const line of rl) {
334
+ if (line.trim()) count++;
335
+ }
336
+ return count;
337
+ }
338
+
339
+ /**
340
+ * Helper: Count reference objects in log
341
+ */
342
+ async function countReferences(filePath) {
343
+ const fileStream = fs.createReadStream(filePath);
344
+ const rl = readline.createInterface({
345
+ input: fileStream,
346
+ crlfDelay: Infinity,
347
+ });
348
+
349
+ let count = 0;
350
+ for await (const line of rl) {
351
+ if (!line.trim()) continue;
352
+ try {
353
+ const entry = JSON.parse(line);
354
+ for (const value of Object.values(entry)) {
355
+ if (typeof value === "object" && value !== null && value.$ref) {
356
+ count++;
357
+ }
358
+ }
359
+ } catch {
360
+ // Skip malformed lines
361
+ }
362
+ }
363
+ return count;
364
+ }
365
+
366
+ /**
367
+ * Main entry point
368
+ */
369
+ async function main() {
370
+ const options = parseArgs();
371
+
372
+ if (options.help) {
373
+ showHelp();
374
+ return;
375
+ }
376
+
377
+ if (options.stats) {
378
+ await showStats(options);
379
+ } else if (options.verify) {
380
+ await verifyReferences(options);
381
+ } else {
382
+ const count = await readLogEntries(options);
383
+ console.error(`\n(Processed ${count} entries)`);
384
+ }
385
+ }
386
+
387
+ // Run if called directly
388
+ if (require.main === module) {
389
+ main().catch((err) => {
390
+ console.error("Error:", err.message);
391
+ process.exit(1);
392
+ });
393
+ }
394
+
395
+ module.exports = {
396
+ readLogEntries,
397
+ showStats,
398
+ verifyReferences,
399
+ };
@@ -2,11 +2,9 @@
2
2
  /**
3
3
  * Calibrate tier thresholds from telemetry.
4
4
  *
5
- * Phase 1.4 of the routing overhaul. Reads quality_score history from the
6
- * routing_telemetry table, finds where each tier's median quality drops below
7
- * acceptable, and writes adjusted [lo, hi] ranges to
8
- * data/calibrated-thresholds.json. ModelTierSelector picks the file up on
9
- * next start.
5
+ * CLI wrapper around `src/routing/calibration.js`. WS5.6 moved the core
6
+ * logic into a module so the same code path drives both this manual
7
+ * script and the in-process auto-calibration scheduler.
10
8
  *
11
9
  * Usage: node scripts/calibrate-thresholds.js [--days N] [--dry-run]
12
10
  * npx lynkr calibrate
@@ -16,31 +14,9 @@
16
14
  * - Exits 0 with a "skipped" message.
17
15
  */
18
16
 
19
- const fs = require('fs');
20
- const path = require('path');
17
+ const { runCalibration } = require('../src/routing/calibration');
21
18
 
22
19
  const DEFAULT_DAYS = 7;
23
- const MIN_SAMPLES = 100;
24
- /** Quality score below which a complexity bucket is "underperforming" for its tier. */
25
- const QUALITY_FLOOR = {
26
- SIMPLE: 55,
27
- MEDIUM: 60,
28
- COMPLEX: 65,
29
- REASONING: 70,
30
- };
31
-
32
- const OUTPUT_PATH = path.join(__dirname, '../data/calibrated-thresholds.json');
33
- const TELEMETRY_DB_CANDIDATES = [
34
- path.join(__dirname, '../.lynkr/telemetry.db'),
35
- path.join(__dirname, '../data/lynkr.db'),
36
- ];
37
-
38
- function _findDb() {
39
- for (const p of TELEMETRY_DB_CANDIDATES) {
40
- if (fs.existsSync(p)) return p;
41
- }
42
- return null;
43
- }
44
20
 
45
21
  function _parseArgs(argv) {
46
22
  const out = { days: DEFAULT_DAYS, dryRun: false };
@@ -52,141 +28,46 @@ function _parseArgs(argv) {
52
28
  return out;
53
29
  }
54
30
 
55
- const DEFAULT_RANGES = {
56
- SIMPLE: [0, 25],
57
- MEDIUM: [26, 50],
58
- COMPLEX: [51, 75],
59
- REASONING: [76, 100],
60
- };
61
-
62
- function _openDb(dbPath) {
63
- let Database;
64
- try {
65
- Database = require('better-sqlite3');
66
- } catch (err) {
67
- console.error('better-sqlite3 not installed. Install with: npm install --save-optional better-sqlite3');
68
- process.exit(2);
31
+ function _reportSkipped(result) {
32
+ switch (result.reason) {
33
+ case 'no_db':
34
+ console.log('No telemetry DB found — skipping calibration.');
35
+ break;
36
+ case 'db_open_failed':
37
+ console.error(`Failed to open telemetry DB: ${result.error}`);
38
+ process.exit(2);
39
+ break;
40
+ case 'query_failed':
41
+ console.error(`Telemetry query failed (DB may be corrupt or schema missing): ${result.error}`);
42
+ break;
43
+ case 'insufficient_samples':
44
+ console.log(
45
+ `Only ${result.count} rows with quality_score (need ≥${result.minSamples}). Skipping.`
46
+ );
47
+ break;
48
+ case 'write_failed':
49
+ console.error(`Failed to write calibrated thresholds: ${result.error}`);
50
+ process.exit(2);
51
+ break;
52
+ default:
53
+ console.log(`Skipped: ${result.reason}`);
69
54
  }
70
- return new Database(dbPath, { readonly: true, fileMustExist: true });
71
55
  }
72
56
 
73
- function calibrate({ days = DEFAULT_DAYS, dryRun = false } = {}) {
74
- const dbPath = _findDb();
75
- if (!dbPath) {
76
- console.log('No telemetry DB found — skipping calibration.');
77
- return { skipped: true, reason: 'no_db' };
78
- }
79
-
80
- let db;
81
- try {
82
- db = _openDb(dbPath);
83
- } catch (err) {
84
- console.error(`Failed to open telemetry DB: ${err.message}`);
85
- return { skipped: true, reason: 'db_open_failed', error: err.message };
86
- }
87
-
88
- const since = Date.now() - days * 24 * 3600 * 1000;
89
- let rows;
90
- try {
91
- rows = db
92
- .prepare(
93
- `SELECT tier, complexity_score AS score, quality_score AS q
94
- FROM routing_telemetry
95
- WHERE timestamp >= ?
96
- AND quality_score IS NOT NULL
97
- AND complexity_score IS NOT NULL
98
- AND tier IS NOT NULL`
99
- )
100
- .all(since);
101
- } catch (err) {
102
- console.error(`Telemetry query failed (DB may be corrupt or schema missing): ${err.message}`);
103
- return { skipped: true, reason: 'query_failed', error: err.message };
104
- } finally {
105
- try { db.close(); } catch {}
57
+ function calibrate(opts) {
58
+ const result = runCalibration(opts);
59
+ if (result.skipped) {
60
+ _reportSkipped(result);
61
+ return result;
106
62
  }
107
-
108
- if (!rows || rows.length < MIN_SAMPLES) {
109
- console.log(`Only ${rows ? rows.length : 0} rows with quality_score in last ${days}d (need ≥${MIN_SAMPLES}). Skipping.`);
110
- return { skipped: true, reason: 'insufficient_samples', count: rows ? rows.length : 0 };
111
- }
112
-
113
- // Bucket by score (0-100 in width-5 buckets) per tier, compute median quality.
114
- const buckets = new Map(); // tier -> Map<bucketLowerBound, q-values[]>
115
- for (const row of rows) {
116
- const s = Math.max(0, Math.min(100, Math.floor(row.score)));
117
- const bucket = Math.floor(s / 5) * 5;
118
- if (!buckets.has(row.tier)) buckets.set(row.tier, new Map());
119
- const b = buckets.get(row.tier);
120
- if (!b.has(bucket)) b.set(bucket, []);
121
- b.get(bucket).push(row.q);
63
+ if (result.dryRun) {
64
+ console.log(JSON.stringify(result, null, 2));
65
+ return result;
122
66
  }
123
-
124
- const _median = (arr) => {
125
- const s = arr.slice().sort((a, b) => a - b);
126
- const m = Math.floor(s.length / 2);
127
- return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
128
- };
129
-
130
- // Default ranges; will adjust per-tier upper bound if late buckets show poor quality.
131
- const ranges = { ...DEFAULT_RANGES };
132
67
  const tierOrder = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
133
- const stats = {};
134
-
135
- for (const tier of tierOrder) {
136
- const floor = QUALITY_FLOOR[tier];
137
- const tierBuckets = buckets.get(tier);
138
- if (!tierBuckets) {
139
- stats[tier] = { samples: 0, adjusted: false };
140
- continue;
141
- }
142
- const ordered = Array.from(tierBuckets.entries()).sort((a, b) => a[0] - b[0]);
143
- let suggestedUpper = DEFAULT_RANGES[tier][1];
144
- const buckets_summary = [];
145
- for (const [lo, vals] of ordered) {
146
- if (vals.length < 5) {
147
- buckets_summary.push({ bucket: lo, samples: vals.length, median: null });
148
- continue;
149
- }
150
- const med = _median(vals);
151
- buckets_summary.push({ bucket: lo, samples: vals.length, median: med });
152
- if (med < floor && lo + 4 < suggestedUpper) {
153
- suggestedUpper = lo + 4; // shrink tier upper bound just below the failing bucket
154
- }
155
- }
156
- if (suggestedUpper !== DEFAULT_RANGES[tier][1]) {
157
- ranges[tier] = [DEFAULT_RANGES[tier][0], suggestedUpper];
158
- stats[tier] = { samples: ordered.reduce((s, [, v]) => s + v.length, 0), adjusted: true, buckets: buckets_summary };
159
- } else {
160
- stats[tier] = { samples: ordered.reduce((s, [, v]) => s + v.length, 0), adjusted: false, buckets: buckets_summary };
161
- }
162
- }
163
-
164
- // Re-stitch ranges so they don't overlap or leave gaps.
165
- for (let i = 1; i < tierOrder.length; i++) {
166
- const prev = ranges[tierOrder[i - 1]];
167
- const cur = ranges[tierOrder[i]];
168
- if (cur[0] !== prev[1] + 1) cur[0] = prev[1] + 1;
169
- if (cur[0] > cur[1]) cur[1] = cur[0]; // collapsed; tier disabled in practice
170
- }
171
-
172
- const out = {
173
- calibratedAt: new Date().toISOString(),
174
- days,
175
- sampleCount: rows.length,
176
- ranges,
177
- stats,
178
- };
179
-
180
- if (dryRun) {
181
- console.log(JSON.stringify(out, null, 2));
182
- return { ...out, dryRun: true };
183
- }
184
-
185
- fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
186
- fs.writeFileSync(OUTPUT_PATH, JSON.stringify(out, null, 2));
187
- console.log(`Wrote ${OUTPUT_PATH}`);
188
- console.log(`Ranges: ${tierOrder.map((t) => `${t}=${ranges[t].join('-')}`).join(', ')}`);
189
- return out;
68
+ console.log(`Wrote ${result.writtenTo}`);
69
+ console.log(`Ranges: ${tierOrder.map((t) => `${t}=${result.ranges[t].join('-')}`).join(', ')}`);
70
+ return result;
190
71
  }
191
72
 
192
73
  if (require.main === module) {