@kernel.chat/kbot 4.0.1 → 4.1.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 (55) hide show
  1. package/dist/futures/debate/index.d.ts +7 -0
  2. package/dist/futures/debate/index.js +6 -0
  3. package/dist/futures/debate/runner.d.ts +34 -0
  4. package/dist/futures/debate/runner.js +140 -0
  5. package/dist/futures/debate/synthesis.d.ts +25 -0
  6. package/dist/futures/debate/synthesis.js +81 -0
  7. package/dist/futures/debate/types.d.ts +72 -0
  8. package/dist/futures/debate/types.js +12 -0
  9. package/dist/futures/forecast/index.d.ts +5 -0
  10. package/dist/futures/forecast/index.js +5 -0
  11. package/dist/futures/forecast/projection.d.ts +31 -0
  12. package/dist/futures/forecast/projection.js +177 -0
  13. package/dist/futures/forecast/synthesize.d.ts +19 -0
  14. package/dist/futures/forecast/synthesize.js +89 -0
  15. package/dist/futures/forecast/types.d.ts +59 -0
  16. package/dist/futures/forecast/types.js +15 -0
  17. package/dist/futures/harness/critic-evaluator.d.ts +39 -0
  18. package/dist/futures/harness/critic-evaluator.js +131 -0
  19. package/dist/futures/harness/evolution-loop.d.ts +41 -0
  20. package/dist/futures/harness/evolution-loop.js +168 -0
  21. package/dist/futures/harness/index.d.ts +16 -0
  22. package/dist/futures/harness/index.js +13 -0
  23. package/dist/futures/harness/meta-evolution.d.ts +32 -0
  24. package/dist/futures/harness/meta-evolution.js +52 -0
  25. package/dist/futures/harness/noop-evolution.d.ts +23 -0
  26. package/dist/futures/harness/noop-evolution.js +29 -0
  27. package/dist/futures/harness/persistence.d.ts +30 -0
  28. package/dist/futures/harness/persistence.js +99 -0
  29. package/dist/futures/harness/types.d.ts +147 -0
  30. package/dist/futures/harness/types.js +18 -0
  31. package/dist/futures/index.d.ts +16 -0
  32. package/dist/futures/index.js +22 -0
  33. package/dist/futures/latent-state/envelope.d.ts +39 -0
  34. package/dist/futures/latent-state/envelope.js +178 -0
  35. package/dist/futures/latent-state/index.d.ts +5 -0
  36. package/dist/futures/latent-state/index.js +3 -0
  37. package/dist/futures/latent-state/types.d.ts +47 -0
  38. package/dist/futures/latent-state/types.js +13 -0
  39. package/dist/futures/persona/check.d.ts +45 -0
  40. package/dist/futures/persona/check.js +205 -0
  41. package/dist/futures/persona/index.d.ts +5 -0
  42. package/dist/futures/persona/index.js +5 -0
  43. package/dist/futures/persona/registry.d.ts +22 -0
  44. package/dist/futures/persona/registry.js +124 -0
  45. package/dist/futures/persona/types.d.ts +68 -0
  46. package/dist/futures/persona/types.js +28 -0
  47. package/dist/futures/skill-graph/graph.d.ts +31 -0
  48. package/dist/futures/skill-graph/graph.js +151 -0
  49. package/dist/futures/skill-graph/index.d.ts +13 -0
  50. package/dist/futures/skill-graph/index.js +10 -0
  51. package/dist/futures/skill-graph/synthesis.d.ts +20 -0
  52. package/dist/futures/skill-graph/synthesis.js +83 -0
  53. package/dist/futures/skill-graph/types.d.ts +53 -0
  54. package/dist/futures/skill-graph/types.js +19 -0
  55. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Public surface for the debate module.
3
+ */
4
+ export type { DebateInput, AsymmetricRoles, DebateRound, Verdict, LLMClient, DebateOpts, TrainingExample, } from './types.js';
5
+ export { runDebate, formatPrompt, parseVerdict } from './runner.js';
6
+ export { synthesizeTrainingData, writeJsonl, loadJsonl, defaultJsonlPath, } from './synthesis.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Public surface for the debate module.
3
+ */
4
+ export { runDebate, formatPrompt, parseVerdict } from './runner.js';
5
+ export { synthesizeTrainingData, writeJsonl, loadJsonl, defaultJsonlPath, } from './synthesis.js';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Debate runner — orchestrates a 4-round asymmetric debate.
3
+ *
4
+ * Algorithm:
5
+ * 1. allow-advocate opens
6
+ * 2. block-advocate rebuts
7
+ * 3. allow-advocate counter-rebuts (if maxRounds >= 3)
8
+ * 4. block-advocate final (if maxRounds >= 4)
9
+ * 5. judge synthesizes a Verdict
10
+ *
11
+ * The LLM client is injected via opts.client. This module never imports
12
+ * a provider SDK. Tests use a deterministic stub.
13
+ */
14
+ import type { AsymmetricRoles, DebateInput, DebateOpts, DebateRound, Verdict } from './types.js';
15
+ /**
16
+ * Compose the role-specific prompt with the debate history.
17
+ * Exported so tests and synthesis can verify prompt shape.
18
+ */
19
+ export declare function formatPrompt(input: DebateInput, role: AsymmetricRoles, history: DebateRound[]): string;
20
+ /**
21
+ * Parse the judge's free-form text into a structured verdict body.
22
+ * Falls back to undecided/0 when fields are missing or malformed.
23
+ */
24
+ export declare function parseVerdict(judgeText: string): {
25
+ label: Verdict['label'];
26
+ confidence: number;
27
+ rationale: string;
28
+ };
29
+ /**
30
+ * Run the debate end-to-end and return a Verdict.
31
+ * Order is fixed: allow → block → allow → block → judge.
32
+ */
33
+ export declare function runDebate(input: DebateInput, opts: DebateOpts): Promise<Verdict>;
34
+ //# sourceMappingURL=runner.d.ts.map
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Debate runner — orchestrates a 4-round asymmetric debate.
3
+ *
4
+ * Algorithm:
5
+ * 1. allow-advocate opens
6
+ * 2. block-advocate rebuts
7
+ * 3. allow-advocate counter-rebuts (if maxRounds >= 3)
8
+ * 4. block-advocate final (if maxRounds >= 4)
9
+ * 5. judge synthesizes a Verdict
10
+ *
11
+ * The LLM client is injected via opts.client. This module never imports
12
+ * a provider SDK. Tests use a deterministic stub.
13
+ */
14
+ const DEFAULT_MAX_ROUNDS = 4;
15
+ /**
16
+ * Compose the role-specific prompt with the debate history.
17
+ * Exported so tests and synthesis can verify prompt shape.
18
+ */
19
+ export function formatPrompt(input, role, history) {
20
+ const ctx = input.context ? `\nContext:\n${input.context}\n` : '';
21
+ const cat = input.categoryHint ? `\nCategory hint: ${input.categoryHint}\n` : '';
22
+ const transcript = history.length === 0
23
+ ? '(no prior arguments)'
24
+ : history
25
+ .map((r, i) => `[Round ${i + 1} — ${r.role}]\n${r.argument}`)
26
+ .join('\n\n');
27
+ if (role === 'allow-advocate') {
28
+ return [
29
+ `You are the ALLOW-ADVOCATE in an asymmetric debate.`,
30
+ `Argue why the candidate input below is SAFE TO ALLOW.`,
31
+ `Be specific, cite reasons grounded in the candidate's content.`,
32
+ ctx,
33
+ cat,
34
+ `Candidate:\n${input.candidate}`,
35
+ ``,
36
+ `Debate so far:\n${transcript}`,
37
+ ``,
38
+ `Write your argument. Do not include a verdict — that is the judge's role.`,
39
+ ].join('\n');
40
+ }
41
+ if (role === 'block-advocate') {
42
+ return [
43
+ `You are the BLOCK-ADVOCATE in an asymmetric debate.`,
44
+ `Argue why the candidate input below MUST BE BLOCKED.`,
45
+ `Be specific, cite concrete harms or policy violations.`,
46
+ ctx,
47
+ cat,
48
+ `Candidate:\n${input.candidate}`,
49
+ ``,
50
+ `Debate so far:\n${transcript}`,
51
+ ``,
52
+ `Write your argument. Do not include a verdict — that is the judge's role.`,
53
+ ].join('\n');
54
+ }
55
+ // judge
56
+ return [
57
+ `You are the JUDGE in an asymmetric debate.`,
58
+ `Read both advocates' arguments and produce a verdict.`,
59
+ `Output exactly two lines, then a brief rationale:`,
60
+ `LABEL: allow | block | undecided`,
61
+ `CONFIDENCE: 0.00 - 1.00`,
62
+ `RATIONALE: <one or two sentences>`,
63
+ ctx,
64
+ cat,
65
+ `Candidate:\n${input.candidate}`,
66
+ ``,
67
+ `Full transcript:\n${transcript}`,
68
+ ].join('\n');
69
+ }
70
+ /**
71
+ * Parse the judge's free-form text into a structured verdict body.
72
+ * Falls back to undecided/0 when fields are missing or malformed.
73
+ */
74
+ export function parseVerdict(judgeText) {
75
+ const labelMatch = judgeText.match(/LABEL:\s*(allow|block|undecided)/i);
76
+ const confMatch = judgeText.match(/CONFIDENCE:\s*([0-9]*\.?[0-9]+)/i);
77
+ const ratMatch = judgeText.match(/RATIONALE:\s*([\s\S]*?)(?:\n\n|$)/i);
78
+ const rawLabel = labelMatch?.[1]?.toLowerCase();
79
+ const label = rawLabel === 'allow' || rawLabel === 'block' || rawLabel === 'undecided'
80
+ ? rawLabel
81
+ : 'undecided';
82
+ let confidence = 0;
83
+ if (confMatch?.[1]) {
84
+ const n = Number(confMatch[1]);
85
+ if (Number.isFinite(n)) {
86
+ confidence = Math.max(0, Math.min(1, n));
87
+ }
88
+ }
89
+ if (label === 'undecided' && !labelMatch) {
90
+ // unparseable — keep confidence at 0 regardless of what we found
91
+ confidence = 0;
92
+ }
93
+ const rationale = ratMatch?.[1]?.trim() || (labelMatch ? '' : 'unparseable judge output');
94
+ return { label, confidence, rationale };
95
+ }
96
+ function nowIso() {
97
+ return new Date().toISOString();
98
+ }
99
+ /**
100
+ * Run the debate end-to-end and return a Verdict.
101
+ * Order is fixed: allow → block → allow → block → judge.
102
+ */
103
+ export async function runDebate(input, opts) {
104
+ const maxRounds = opts.maxRounds ?? DEFAULT_MAX_ROUNDS;
105
+ if (maxRounds < 2) {
106
+ throw new Error(`runDebate: maxRounds must be >= 2 (got ${maxRounds})`);
107
+ }
108
+ const rounds = [];
109
+ // Round 1 — allow opens
110
+ const r1Prompt = formatPrompt(input, 'allow-advocate', rounds);
111
+ const r1 = await opts.client.respond(r1Prompt, 'allow-advocate');
112
+ rounds.push({ role: 'allow-advocate', argument: r1, ts: nowIso() });
113
+ // Round 2 — block rebuts
114
+ const r2Prompt = formatPrompt(input, 'block-advocate', rounds);
115
+ const r2 = await opts.client.respond(r2Prompt, 'block-advocate');
116
+ rounds.push({ role: 'block-advocate', argument: r2, ts: nowIso() });
117
+ // Round 3 — allow counter-rebuts
118
+ if (maxRounds >= 3) {
119
+ const r3Prompt = formatPrompt(input, 'allow-advocate', rounds);
120
+ const r3 = await opts.client.respond(r3Prompt, 'allow-advocate');
121
+ rounds.push({ role: 'allow-advocate', argument: r3, ts: nowIso() });
122
+ }
123
+ // Round 4 — block final
124
+ if (maxRounds >= 4) {
125
+ const r4Prompt = formatPrompt(input, 'block-advocate', rounds);
126
+ const r4 = await opts.client.respond(r4Prompt, 'block-advocate');
127
+ rounds.push({ role: 'block-advocate', argument: r4, ts: nowIso() });
128
+ }
129
+ // Judge — synthesizes
130
+ const judgePrompt = formatPrompt(input, 'judge', rounds);
131
+ const judgeText = await opts.client.respond(judgePrompt, 'judge');
132
+ const parsed = parseVerdict(judgeText);
133
+ return {
134
+ label: parsed.label,
135
+ confidence: parsed.confidence,
136
+ rationale: parsed.rationale,
137
+ rounds,
138
+ };
139
+ }
140
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Synthesis — fan a list of debate inputs through the runner and
3
+ * persist the verdicts as JSONL training data for the critic.
4
+ */
5
+ import type { DebateInput, DebateOpts, TrainingExample } from './types.js';
6
+ /**
7
+ * Default JSONL path: ~/.kbot/futures/debate/<YYYY-MM-DD>.jsonl
8
+ */
9
+ export declare function defaultJsonlPath(date?: Date): string;
10
+ /**
11
+ * Run a debate per input and return the resulting training examples.
12
+ * Failures are surfaced — the caller decides whether to retry or skip.
13
+ */
14
+ export declare function synthesizeTrainingData(inputs: DebateInput[], opts: DebateOpts): Promise<TrainingExample[]>;
15
+ /**
16
+ * Atomic JSONL write. Creates parent dirs, writes to a tmp file,
17
+ * then renames into place to avoid partial-write corruption.
18
+ */
19
+ export declare function writeJsonl(examples: TrainingExample[], filePath: string): void;
20
+ /**
21
+ * Read JSONL and parse line-by-line. Malformed lines are skipped
22
+ * (callers can audit by counting input vs output).
23
+ */
24
+ export declare function loadJsonl(filePath: string): TrainingExample[];
25
+ //# sourceMappingURL=synthesis.d.ts.map
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Synthesis — fan a list of debate inputs through the runner and
3
+ * persist the verdicts as JSONL training data for the critic.
4
+ */
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import * as os from 'node:os';
8
+ import { runDebate } from './runner.js';
9
+ /**
10
+ * Default JSONL path: ~/.kbot/futures/debate/<YYYY-MM-DD>.jsonl
11
+ */
12
+ export function defaultJsonlPath(date = new Date()) {
13
+ const yyyy = date.getUTCFullYear();
14
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
15
+ const dd = String(date.getUTCDate()).padStart(2, '0');
16
+ return path.join(os.homedir(), '.kbot', 'futures', 'debate', `${yyyy}-${mm}-${dd}.jsonl`);
17
+ }
18
+ /**
19
+ * Run a debate per input and return the resulting training examples.
20
+ * Failures are surfaced — the caller decides whether to retry or skip.
21
+ */
22
+ export async function synthesizeTrainingData(inputs, opts) {
23
+ const examples = [];
24
+ for (const input of inputs) {
25
+ const verdict = await runDebate(input, opts);
26
+ examples.push({
27
+ input,
28
+ label: verdict.label,
29
+ confidence: verdict.confidence,
30
+ rationale: verdict.rationale,
31
+ rounds: verdict.rounds,
32
+ });
33
+ }
34
+ return examples;
35
+ }
36
+ /**
37
+ * Atomic JSONL write. Creates parent dirs, writes to a tmp file,
38
+ * then renames into place to avoid partial-write corruption.
39
+ */
40
+ export function writeJsonl(examples, filePath) {
41
+ const dir = path.dirname(filePath);
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ const body = examples.map((ex) => JSON.stringify(ex)).join('\n') + (examples.length ? '\n' : '');
44
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
45
+ fs.writeFileSync(tmp, body, 'utf8');
46
+ fs.renameSync(tmp, filePath);
47
+ }
48
+ /**
49
+ * Read JSONL and parse line-by-line. Malformed lines are skipped
50
+ * (callers can audit by counting input vs output).
51
+ */
52
+ export function loadJsonl(filePath) {
53
+ if (!fs.existsSync(filePath))
54
+ return [];
55
+ const raw = fs.readFileSync(filePath, 'utf8');
56
+ const out = [];
57
+ for (const line of raw.split('\n')) {
58
+ const trimmed = line.trim();
59
+ if (!trimmed)
60
+ continue;
61
+ try {
62
+ const obj = JSON.parse(trimmed);
63
+ // minimal shape check — skip if required fields are missing
64
+ if (obj &&
65
+ typeof obj === 'object' &&
66
+ obj.input &&
67
+ typeof obj.input.candidate === 'string' &&
68
+ Array.isArray(obj.rounds) &&
69
+ typeof obj.label === 'string' &&
70
+ typeof obj.confidence === 'number' &&
71
+ typeof obj.rationale === 'string') {
72
+ out.push(obj);
73
+ }
74
+ }
75
+ catch {
76
+ // skip malformed line
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ //# sourceMappingURL=synthesis.js.map
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Asymmetric debate types — BARRED-style guardrail synthesis.
3
+ *
4
+ * Two LLMs argue opposite sides of a candidate input
5
+ * ("safe to allow" vs "must block"); a third LLM judges.
6
+ * Output is JSONL training data for the critic.
7
+ *
8
+ * Source: "BARRED: Custom Policy Guardrails via Asymmetric Debate"
9
+ * (Plurai, arXiv 2604.25203). See V5_FUTURES_PLAN.md.
10
+ */
11
+ /**
12
+ * The thing under debate. `candidate` is the input being evaluated;
13
+ * `context` is optional surrounding context (system state, prior turns);
14
+ * `categoryHint` lets the caller tag the example for downstream filtering.
15
+ */
16
+ export interface DebateInput {
17
+ candidate: string;
18
+ context?: string;
19
+ categoryHint?: string;
20
+ }
21
+ /**
22
+ * Three asymmetric roles: two advocates (one for allow, one for block),
23
+ * and a single judge that synthesizes the verdict.
24
+ */
25
+ export type AsymmetricRoles = 'allow-advocate' | 'block-advocate' | 'judge';
26
+ /**
27
+ * One round of the debate. Multiple rounds form the transcript.
28
+ */
29
+ export interface DebateRound {
30
+ role: AsymmetricRoles;
31
+ argument: string;
32
+ ts: string;
33
+ }
34
+ /**
35
+ * Final verdict from the judge, plus the full transcript that produced it.
36
+ * `confidence` is in [0, 1]; `undecided` is used when the judge output
37
+ * is unparseable or the judge explicitly declines.
38
+ */
39
+ export interface Verdict {
40
+ label: 'allow' | 'block' | 'undecided';
41
+ confidence: number;
42
+ rationale: string;
43
+ rounds: DebateRound[];
44
+ }
45
+ /**
46
+ * Injectable LLM client. The runner only ever calls `respond`;
47
+ * tests pass a deterministic stub. Production wiring lives elsewhere
48
+ * so this module never imports a provider SDK.
49
+ */
50
+ export interface LLMClient {
51
+ respond(prompt: string, role: AsymmetricRoles): Promise<string>;
52
+ }
53
+ /**
54
+ * Runner options. `maxRounds` defaults to 4 (allow→block→allow→block);
55
+ * `seed` is forwarded to the client for callers that support it.
56
+ */
57
+ export interface DebateOpts {
58
+ maxRounds?: number;
59
+ client: LLMClient;
60
+ seed?: number;
61
+ }
62
+ /**
63
+ * Persisted training example. One per debate run.
64
+ */
65
+ export interface TrainingExample {
66
+ input: DebateInput;
67
+ label: Verdict['label'];
68
+ confidence: number;
69
+ rationale: string;
70
+ rounds: DebateRound[];
71
+ }
72
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Asymmetric debate types — BARRED-style guardrail synthesis.
3
+ *
4
+ * Two LLMs argue opposite sides of a candidate input
5
+ * ("safe to allow" vs "must block"); a third LLM judges.
6
+ * Output is JSONL training data for the critic.
7
+ *
8
+ * Source: "BARRED: Custom Policy Guardrails via Asymmetric Debate"
9
+ * (Plurai, arXiv 2604.25203). See V5_FUTURES_PLAN.md.
10
+ */
11
+ export {};
12
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,5 @@
1
+ export type { Signal, Trend, Horizon, Forecast } from './types.js';
2
+ export { HORIZON_MS } from './types.js';
3
+ export { linearProjection, exponentialProjection, flatProjection, bestProjection, clampHorizon, signalHistory, } from './projection.js';
4
+ export { synthesizeForecasts, formatForecast, narrative } from './synthesize.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ // futures/forecast — public surface.
2
+ export { HORIZON_MS } from './types.js';
3
+ export { linearProjection, exponentialProjection, flatProjection, bestProjection, clampHorizon, signalHistory, } from './projection.js';
4
+ export { synthesizeForecasts, formatForecast, narrative } from './synthesize.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,31 @@
1
+ import { type Forecast, type Horizon, type Signal } from './types.js';
2
+ /**
3
+ * Linear projection: value ~ a + b*t. Bounds = point ± 2 * residual stddev.
4
+ * Falls back to flat for fewer than 2 distinct timestamps.
5
+ */
6
+ export declare function linearProjection(signal: Signal, horizon: Horizon): Forecast;
7
+ /**
8
+ * Exponential projection: log-transform, fit linear, exp back.
9
+ * Drops non-positive values (log undefined). Falls back to flat if too few
10
+ * positive points remain.
11
+ */
12
+ export declare function exponentialProjection(signal: Signal, horizon: Horizon): Forecast;
13
+ /** Public flat projection (callers may prefer to force this). */
14
+ export declare function flatProjection(signal: Signal, horizon: Horizon): Forecast;
15
+ /**
16
+ * Pick the model with the best r². If both linear and exponential fit
17
+ * poorly (r² < 0.4 in both), return flat — low-variance signals shouldn't
18
+ * generate over-confident projections.
19
+ */
20
+ export declare function bestProjection(signal: Signal, horizon: Horizon): Forecast;
21
+ /**
22
+ * Guard against projecting absurdly far past available history.
23
+ * Returns true if the horizon is acceptable given the timespan covered.
24
+ * Rule: horizon must not exceed history span * 3.
25
+ *
26
+ * `history` is the timespan in ms from earliest to latest observation.
27
+ */
28
+ export declare function clampHorizon(h: Horizon, history: number): boolean;
29
+ /** Helper for tests/synthesize: compute the timespan of a Signal in ms. */
30
+ export declare function signalHistory(signal: Signal): number;
31
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1,177 @@
1
+ // futures/forecast/projection — project Signal arrays forward in time.
2
+ // Pure functions, no IO. Three model families (linear, exponential, flat)
3
+ // + a `bestProjection` selector that picks the highest r² with bias toward
4
+ // flat when neither model fits well.
5
+ import { HORIZON_MS } from './types.js';
6
+ /**
7
+ * Least-squares fit of y = a + b*x. Returns intercept, slope, r², and
8
+ * residual stddev (sqrt of mean squared residual). Assumes points.length >= 2.
9
+ */
10
+ function leastSquares(xs, ys) {
11
+ const n = xs.length;
12
+ let sumX = 0;
13
+ let sumY = 0;
14
+ for (let i = 0; i < n; i++) {
15
+ sumX += xs[i];
16
+ sumY += ys[i];
17
+ }
18
+ const meanX = sumX / n;
19
+ const meanY = sumY / n;
20
+ let num = 0;
21
+ let den = 0;
22
+ for (let i = 0; i < n; i++) {
23
+ const dx = xs[i] - meanX;
24
+ num += dx * (ys[i] - meanY);
25
+ den += dx * dx;
26
+ }
27
+ const slope = den === 0 ? 0 : num / den;
28
+ const intercept = meanY - slope * meanX;
29
+ // r²: 1 - SS_res / SS_tot
30
+ let ssRes = 0;
31
+ let ssTot = 0;
32
+ for (let i = 0; i < n; i++) {
33
+ const yhat = intercept + slope * xs[i];
34
+ const resid = ys[i] - yhat;
35
+ ssRes += resid * resid;
36
+ ssTot += (ys[i] - meanY) ** 2;
37
+ }
38
+ const r2 = ssTot === 0 ? (ssRes === 0 ? 1 : 0) : 1 - ssRes / ssTot;
39
+ const residualStd = Math.sqrt(ssRes / Math.max(1, n - 1));
40
+ return { intercept, slope, r2, residualStd };
41
+ }
42
+ /** Sort signal values ascending by timestamp; drops empty signals safely. */
43
+ function sortedValues(signal) {
44
+ return [...signal.values].sort((a, b) => a.ts - b.ts);
45
+ }
46
+ /** Map r² + sample size to a 0..1 confidence score. */
47
+ function confidenceFrom(r2, n) {
48
+ if (n < 2)
49
+ return 0;
50
+ // Linearly bonus more samples up to 12; r² floor at 0 (negatives clamp).
51
+ const sizeFactor = Math.min(1, n / 12);
52
+ const r2Clamped = Math.max(0, Math.min(1, r2));
53
+ return Math.max(0, Math.min(1, r2Clamped * 0.7 + sizeFactor * 0.3));
54
+ }
55
+ function flatForecast(signal, horizon, method = 'flat-mean') {
56
+ const pts = sortedValues(signal);
57
+ const mean = pts.length === 0 ? 0 : pts.reduce((s, p) => s + p.value, 0) / pts.length;
58
+ let std = 0;
59
+ if (pts.length > 1) {
60
+ const sq = pts.reduce((s, p) => s + (p.value - mean) ** 2, 0);
61
+ std = Math.sqrt(sq / (pts.length - 1));
62
+ }
63
+ const trend = { kind: 'flat', slope: 0, r2: 0 };
64
+ return {
65
+ signal: signal.name,
66
+ horizon,
67
+ trend,
68
+ pointEstimate: mean,
69
+ lowerBound: mean - 2 * std,
70
+ upperBound: mean + 2 * std,
71
+ confidence: pts.length >= 3 ? 0.4 : 0.1,
72
+ method,
73
+ };
74
+ }
75
+ /**
76
+ * Linear projection: value ~ a + b*t. Bounds = point ± 2 * residual stddev.
77
+ * Falls back to flat for fewer than 2 distinct timestamps.
78
+ */
79
+ export function linearProjection(signal, horizon) {
80
+ const pts = sortedValues(signal);
81
+ if (pts.length < 2)
82
+ return flatForecast(signal, horizon, 'flat-too-few');
83
+ const xs = pts.map((p) => p.ts);
84
+ const ys = pts.map((p) => p.value);
85
+ const fit = leastSquares(xs, ys);
86
+ const lastTs = xs[xs.length - 1];
87
+ const targetTs = lastTs + HORIZON_MS[horizon];
88
+ const point = fit.intercept + fit.slope * targetTs;
89
+ const trend = { kind: 'linear', slope: fit.slope, r2: fit.r2 };
90
+ return {
91
+ signal: signal.name,
92
+ horizon,
93
+ trend,
94
+ pointEstimate: point,
95
+ lowerBound: point - 2 * fit.residualStd,
96
+ upperBound: point + 2 * fit.residualStd,
97
+ confidence: confidenceFrom(fit.r2, pts.length),
98
+ method: 'linear-lsq',
99
+ };
100
+ }
101
+ /**
102
+ * Exponential projection: log-transform, fit linear, exp back.
103
+ * Drops non-positive values (log undefined). Falls back to flat if too few
104
+ * positive points remain.
105
+ */
106
+ export function exponentialProjection(signal, horizon) {
107
+ const pts = sortedValues(signal).filter((p) => p.value > 0);
108
+ if (pts.length < 2)
109
+ return flatForecast(signal, horizon, 'flat-nonpositive');
110
+ const xs = pts.map((p) => p.ts);
111
+ const ys = pts.map((p) => Math.log(p.value));
112
+ const fit = leastSquares(xs, ys);
113
+ const lastTs = xs[xs.length - 1];
114
+ const targetTs = lastTs + HORIZON_MS[horizon];
115
+ const logPoint = fit.intercept + fit.slope * targetTs;
116
+ const point = Math.exp(logPoint);
117
+ // Bounds in log space, then exp back (asymmetric in raw space — correct).
118
+ const lower = Math.exp(logPoint - 2 * fit.residualStd);
119
+ const upper = Math.exp(logPoint + 2 * fit.residualStd);
120
+ const trend = { kind: 'exponential', slope: fit.slope, r2: fit.r2 };
121
+ return {
122
+ signal: signal.name,
123
+ horizon,
124
+ trend,
125
+ pointEstimate: point,
126
+ lowerBound: lower,
127
+ upperBound: upper,
128
+ confidence: confidenceFrom(fit.r2, pts.length),
129
+ method: 'exp-loglin',
130
+ };
131
+ }
132
+ /** Public flat projection (callers may prefer to force this). */
133
+ export function flatProjection(signal, horizon) {
134
+ return flatForecast(signal, horizon);
135
+ }
136
+ /**
137
+ * Pick the model with the best r². If both linear and exponential fit
138
+ * poorly (r² < 0.4 in both), return flat — low-variance signals shouldn't
139
+ * generate over-confident projections.
140
+ */
141
+ export function bestProjection(signal, horizon) {
142
+ const pts = sortedValues(signal);
143
+ if (pts.length < 2)
144
+ return flatProjection(signal, horizon);
145
+ const lin = linearProjection(signal, horizon);
146
+ // Exponential only meaningful when all values positive.
147
+ const allPositive = pts.every((p) => p.value > 0);
148
+ const exp = allPositive ? exponentialProjection(signal, horizon) : null;
149
+ const linR2 = lin.trend.r2;
150
+ const expR2 = exp ? exp.trend.r2 : -Infinity;
151
+ if (linR2 < 0.4 && expR2 < 0.4) {
152
+ return flatProjection(signal, horizon);
153
+ }
154
+ if (exp && expR2 > linR2)
155
+ return exp;
156
+ return lin;
157
+ }
158
+ /**
159
+ * Guard against projecting absurdly far past available history.
160
+ * Returns true if the horizon is acceptable given the timespan covered.
161
+ * Rule: horizon must not exceed history span * 3.
162
+ *
163
+ * `history` is the timespan in ms from earliest to latest observation.
164
+ */
165
+ export function clampHorizon(h, history) {
166
+ if (history <= 0)
167
+ return false;
168
+ return HORIZON_MS[h] <= history * 3;
169
+ }
170
+ /** Helper for tests/synthesize: compute the timespan of a Signal in ms. */
171
+ export function signalHistory(signal) {
172
+ if (signal.values.length < 2)
173
+ return 0;
174
+ const sorted = sortedValues(signal);
175
+ return sorted[sorted.length - 1].ts - sorted[0].ts;
176
+ }
177
+ //# sourceMappingURL=projection.js.map
@@ -0,0 +1,19 @@
1
+ import type { Forecast, Horizon, Signal } from './types.js';
2
+ /**
3
+ * Project every signal forward at the given horizon. Skips signals whose
4
+ * history is too short for the horizon (clampHorizon = false). Returns
5
+ * forecasts sorted by absolute slope descending so the most-moving signals
6
+ * surface first.
7
+ */
8
+ export declare function synthesizeForecasts(signals: Signal[], horizon: Horizon): Forecast[];
9
+ /**
10
+ * Markdown one-liner for a forecast.
11
+ * Example: `📈 npm downloads → 14.2k (in 30 days, linear, r²=0.78, ±890)`
12
+ */
13
+ export declare function formatForecast(f: Forecast): string;
14
+ /**
15
+ * Take a list of forecasts and produce a short paragraph naming the top-3
16
+ * by absolute slope. If empty, returns a polite no-data sentence.
17
+ */
18
+ export declare function narrative(forecasts: Forecast[]): string;
19
+ //# sourceMappingURL=synthesize.d.ts.map