ephemeris-cli 0.0.0-stage → 0.4.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.
- package/README.md +180 -2
- package/assets/DejaVu-LICENSE.txt +78 -0
- package/assets/DejaVuSans.ttf +0 -0
- package/bin/ephemeris.js +7 -0
- package/build-info.json +4 -0
- package/docs/cli-reference.md +594 -0
- package/docs/contracts-and-ownership.md +62 -0
- package/docs/releases.md +23 -0
- package/docs/usability-audit.md +33 -0
- package/examples/daily-sales.csv +15 -0
- package/examples/hourly.json +8 -0
- package/package.json +47 -4
- package/schema/artifact-v1.json +628 -0
- package/schema/data-v1.json +107 -0
- package/schema/openapi.json +659 -0
- package/schema/prepared-v1.json +493 -0
- package/schema/submission-v1.json +539 -0
- package/src/arguments.js +27 -0
- package/src/artifact.js +131 -0
- package/src/cli.js +123 -0
- package/src/client.js +50 -0
- package/src/commands.js +147 -0
- package/src/data.js +103 -0
- package/src/describe.js +87 -0
- package/src/errors.js +14 -0
- package/src/io.js +74 -0
- package/src/plot.js +78 -0
- package/src/render.js +20 -0
- package/src/submission.js +49 -0
- package/src/time.js +95 -0
- package/src/validate.js +69 -0
- package/src/version.js +17 -0
package/src/describe.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { selectForecast } from './artifact.js';
|
|
2
|
+
|
|
3
|
+
export const limitations = [
|
|
4
|
+
'Forecasting alone does not establish accuracy or calibrated uncertainty.',
|
|
5
|
+
'Quantile bands are marginal predictive quantiles, not verified coverage or confidence intervals for model accuracy.',
|
|
6
|
+
'Marginal quantiles cannot establish cumulative-total intervals or path-dependent probabilities.',
|
|
7
|
+
'The highest marginal median is not the distribution of the actual peak.',
|
|
8
|
+
'Observed sales are not necessarily underlying demand. Zeros do not establish stockouts or lost demand.',
|
|
9
|
+
'No causal explanation, trading action, inventory action, or business recommendation is inferred.',
|
|
10
|
+
'Known-future labels and scenario assumptions are caller assertions; availability has not been independently verified.',
|
|
11
|
+
'Absent future covariates or an empty assumptions list means not supplied, not an assumption of zero events, no promotions, no constraints, or unchanged conditions.',
|
|
12
|
+
'A jump from the last observation or a narrow/constant quantile band alone does not establish forecast quality, poor fit, accuracy, or calibration; observed outcomes and evaluation are needed.',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const finite = (v) => Number.isFinite(v) ? v : null;
|
|
16
|
+
export function billingSummary(billing) {
|
|
17
|
+
const amount = (name) => typeof billing?.[name] === 'string' && /^-?\d+$/.test(billing[name]) ? billing[name] : null;
|
|
18
|
+
const credits = (mc) => {
|
|
19
|
+
if (mc === null) return null;
|
|
20
|
+
const negative = mc.startsWith('-'), digits = (negative ? mc.slice(1) : mc).replace(/^0+(?=\d)/, '').padStart(4, '0');
|
|
21
|
+
const fraction = digits.slice(-3).replace(/0+$/, '');
|
|
22
|
+
return `${negative && /[1-9]/.test(digits) ? '-' : ''}${digits.slice(0,-3)}${fraction ? `.${fraction}` : ''}`;
|
|
23
|
+
};
|
|
24
|
+
const settled = amount('settled_mc'), balance = amount('balance_mc');
|
|
25
|
+
return { scope: 'saved response; not a current balance check', unit: 'millicredits', conversion: '1000 millicredits = 1 credit; amounts remain exact decimal strings', settled_mc: settled, settled_credits: credits(settled), balance_mc: balance, balance_credits: credits(balance), source_pointer: '/response/meta/billing' };
|
|
26
|
+
}
|
|
27
|
+
export function mean(values) {
|
|
28
|
+
if (!values.length || values.some((v) => v === null)) return null;
|
|
29
|
+
// Compensated accumulation of scaled terms avoids avoidable sum overflow.
|
|
30
|
+
let sum = 0, correction = 0;
|
|
31
|
+
for (const v of values) { const x = v / values.length - correction; const next = sum + x; correction = (next - sum) - x; sum = next; }
|
|
32
|
+
return finite(sum);
|
|
33
|
+
}
|
|
34
|
+
export function observedStatistics(values) {
|
|
35
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
36
|
+
return { count: values.length, latest: values.at(-1), minimum: sorted[0], maximum: sorted.at(-1), mean: mean(values), median: sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : mean(sorted.slice(sorted.length / 2 - 1, sorted.length / 2 + 1)) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function describe(value, options) {
|
|
40
|
+
const selected = selectForecast(value, options);
|
|
41
|
+
const { artifact: a, i, j, history, context, quantiles, pointer, history_pointer } = selected;
|
|
42
|
+
const median = quantiles['0.5'] ?? null;
|
|
43
|
+
const medianKey = Object.keys(a.response.forecasts[i].quantiles).find((q) => Number(q) === 0.5);
|
|
44
|
+
const last = history.at(-1);
|
|
45
|
+
const end = median?.at(-1) ?? null;
|
|
46
|
+
const complete = median !== null && median.every((v) => v !== null);
|
|
47
|
+
const maximum = complete ? Math.max(...median) : null;
|
|
48
|
+
const medianMean = complete ? mean(median) : null;
|
|
49
|
+
const notes = [...limitations];
|
|
50
|
+
if (!median) notes.push('No median was returned. It is not interpolated from other quantiles.');
|
|
51
|
+
else if (!complete) notes.push('Median contains missing values. Horizon-wide aggregates and maxima are not calculated.');
|
|
52
|
+
if (context.unit === null) notes.push('Unit is unknown. No unit conversion or financial interpretation is performed.');
|
|
53
|
+
if (context.measurement === null) notes.push('Measurement semantics are unknown; no period total is calculated.');
|
|
54
|
+
if (context.future_axis.kind === 'steps') notes.push(context.future_axis.reason);
|
|
55
|
+
const levels = Object.keys(quantiles).map(Number).sort((a, b) => a - b);
|
|
56
|
+
const crossing = Array.from({ length: a.request.horizon ?? 64 }, (_, index) => {
|
|
57
|
+
const observed = levels.map((q) => quantiles[q][index]).filter((v) => v !== null);
|
|
58
|
+
return observed.some((v, k) => k > 0 && v < observed[k - 1]);
|
|
59
|
+
}).filter(Boolean).length;
|
|
60
|
+
if (crossing) notes.push('Returned quantiles cross at some steps. Values are preserved; crossed bands must not be interpreted as ordered intervals.');
|
|
61
|
+
return { schema_version: '1', operation: 'forecast_describe', status: { operation: 'ok', scoring: 'not_performed', evidence_complete: null },
|
|
62
|
+
scope: { series_index: i, variate_index: j, series_id: context.series_id, unit: context.unit, variable: context.target_description, measurement: context.measurement, timezone: context.timezone, frequency: a.request.series[i].freq ?? null, horizon: a.request.horizon ?? 64, horizon_unit: 'sampling_steps', axis: context.future_axis },
|
|
63
|
+
result: {
|
|
64
|
+
billing: billingSummary(a.response.meta?.billing),
|
|
65
|
+
point_definition: 'marginal_median', point_pointer: median ? `${pointer}/${medianKey}${Array.isArray(a.request.series[i].values[0]) ? `/${j}` : ''}` : null,
|
|
66
|
+
...(median && median.length <= 8 ? { point: median } : {}), quantiles_pointer: pointer,
|
|
67
|
+
observed: { ...observedStatistics(history), source_pointer: history_pointer, scope: 'full submitted history; service may truncate context' },
|
|
68
|
+
median_missing_count: median ? median.filter((v) => v === null).length : null,
|
|
69
|
+
final_median: end,
|
|
70
|
+
change_from_last_observed: end === null ? null : finite(end - last),
|
|
71
|
+
percent_change_from_last_observed: end === null || last === 0 ? null : finite(((end - last) / Math.abs(last)) * 100),
|
|
72
|
+
mean_of_marginal_medians: medianMean,
|
|
73
|
+
sum_of_marginal_medians: medianMean !== null && context.measurement === 'period_total' ? finite(medianMean * median.length) : null,
|
|
74
|
+
sum_interpretation: context.measurement === 'period_total' ? 'Arithmetic sum of per-step medians, not the median or interval of the cumulative distribution.' : 'Not calculated: measurement is not declared period_total.',
|
|
75
|
+
highest_marginal_median: maximum === null ? null : { value: maximum, steps: median.flatMap((v, k) => v === maximum ? [k + 1] : []) },
|
|
76
|
+
quantile_crossing_steps: crossing,
|
|
77
|
+
},
|
|
78
|
+
basis: { kind: 'deterministic_saved_result', baseline: { name: 'last_observed_value', value: last, pointer: `${history_pointer}/${history.length - 1}` }, methods: {
|
|
79
|
+
observed_mean: 'Compensated arithmetic mean of all submitted observations.', observed_median: 'Middle sorted observation, or arithmetic mean of the two middle observations.',
|
|
80
|
+
change: 'Final marginal median minus last observed value.', percent_change: '100 * (final marginal median - last observation) / abs(last observation); null for a zero baseline.',
|
|
81
|
+
aggregates: 'Arithmetic calculations on marginal medians only; null for missing inputs or non-finite arithmetic.', peak: 'Maximum marginal median with all tied one-based steps; not a peak-outcome distribution.',
|
|
82
|
+
}, provider_calls: 0, local_context_pointer: '/context', models_used_pointer: '/response/meta/models_used' },
|
|
83
|
+
limitations: notes,
|
|
84
|
+
references: { artifact_id: a.artifact_id, request_fingerprint: a.request_sha256, snapshot_id: a.context.snapshot_id, gateway_request_id: a.response.meta?.gateway_request_id ?? a.transport?.request_id ?? null, provider_request_id: a.response.meta?.request_id ?? null, billing_pointer: '/response/meta/billing', pointer_root: 'input forecast artifact', recorded: false },
|
|
85
|
+
followups: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
constructor(code, message, details = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.code = code;
|
|
5
|
+
this.details = details;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function redact(value, key) {
|
|
10
|
+
// Sanitize even upstream errors that echo a bearer token.
|
|
11
|
+
let text = String(value);
|
|
12
|
+
if (key) text = text.split(key).join('[REDACTED]');
|
|
13
|
+
return text.replace(/pc_live_[A-Za-z0-9_-]+/g, '[REDACTED]');
|
|
14
|
+
}
|
package/src/io.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { open, readFile, unlink } from 'node:fs/promises';
|
|
2
|
+
import { CliError, redact } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export async function readText(file) {
|
|
5
|
+
if (!file) throw new CliError(2, '--input is required. Use a file or - for stdin.');
|
|
6
|
+
try {
|
|
7
|
+
let buffer;
|
|
8
|
+
if (file === '-') {
|
|
9
|
+
if (process.stdin.isTTY) throw new CliError(2, 'Pipe input to stdin or provide a file.');
|
|
10
|
+
const chunks = []; let length = 0;
|
|
11
|
+
for await (const chunk of process.stdin) {
|
|
12
|
+
length += chunk.length;
|
|
13
|
+
if (length > 8 * 1024 * 1024) throw new CliError(2, 'Input exceeds the 8 MiB limit. Partition it explicitly.');
|
|
14
|
+
chunks.push(chunk);
|
|
15
|
+
}
|
|
16
|
+
buffer = Buffer.concat(chunks);
|
|
17
|
+
} else {
|
|
18
|
+
const handle = await open(file, 'r');
|
|
19
|
+
try {
|
|
20
|
+
const stats = await handle.stat();
|
|
21
|
+
if (!stats.isFile() || stats.size > 8 * 1024 * 1024) throw new CliError(2, 'Input must be a regular file up to 8 MiB.');
|
|
22
|
+
buffer = await handle.readFile();
|
|
23
|
+
} finally { await handle.close(); }
|
|
24
|
+
}
|
|
25
|
+
if (buffer.length > 8 * 1024 * 1024) throw new CliError(2, 'Input exceeds 8 MiB.');
|
|
26
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof CliError) throw error;
|
|
29
|
+
throw new CliError(8, 'Could not read UTF-8 input. Check the file exists and is readable.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseJson(text) {
|
|
34
|
+
try { return JSON.parse(text); }
|
|
35
|
+
catch { throw new CliError(2, 'Input is not valid JSON. See the command --help for a complete example.'); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sanitize(value, key) {
|
|
39
|
+
if (typeof value === 'string') return redact(value, key);
|
|
40
|
+
if (Array.isArray(value)) return value.map((v) => sanitize(v, key));
|
|
41
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [redact(k, key), /^(?:authorization|api[_-]?key|access[_-]?token|password|secret)$/i.test(k) ? '[REDACTED]' : sanitize(v, key)]));
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function assertNoSecrets(value, key) {
|
|
46
|
+
if (JSON.stringify(sanitize(value, key)) !== JSON.stringify(value)) throw new CliError(2, 'Input contains credential-like content. Remove credentials from data, context, and command arguments; use EPHEMERIS_API_KEY only.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function reserveOutput(file) {
|
|
50
|
+
if (!file || file === '-') return null;
|
|
51
|
+
try { return { file, handle: await open(file, 'wx', 0o600), complete: false }; }
|
|
52
|
+
catch { throw new CliError(8, 'Cannot create output file. Choose a new path in an existing writable directory; existing files are never overwritten.'); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function output(text, target) {
|
|
56
|
+
try {
|
|
57
|
+
if (target) { await target.handle.writeFile(text); await target.handle.sync(); await target.handle.close(); target.complete = true; }
|
|
58
|
+
else await new Promise((resolve, reject) => process.stdout.write(text, (err) => err ? reject(err) : resolve()));
|
|
59
|
+
} catch { throw new CliError(8, 'Output write failed. For paid forecasts retain the retry key and check stdout for the recovered artifact.'); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function cleanup(target) {
|
|
63
|
+
if (!target || target.complete) return;
|
|
64
|
+
await target.handle.close().catch(() => {});
|
|
65
|
+
await unlink(target.file).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function forecastSchema(kind = 'request') {
|
|
69
|
+
if (!['request', 'artifact', 'prepared', 'data', 'submission'].includes(kind)) throw new CliError(2, '--kind must be request, artifact, prepared, data, or submission.');
|
|
70
|
+
if (kind !== 'request') return JSON.parse(await readFile(new URL(`../schema/${kind}-v1.json`, import.meta.url), 'utf8'));
|
|
71
|
+
const spec = JSON.parse(await readFile(new URL('../schema/openapi.json', import.meta.url), 'utf8'));
|
|
72
|
+
const definitions = Object.fromEntries(Object.entries(spec.components.schemas).map(([n, schema]) => [n, JSON.parse(JSON.stringify(schema).replaceAll('#/components/schemas/', '#/$defs/'))]));
|
|
73
|
+
return { $schema: 'https://json-schema.org/draft/2020-12/schema', $ref: '#/$defs/ForecastRequest', $defs: definitions };
|
|
74
|
+
}
|
package/src/plot.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { selectForecast } from './artifact.js';
|
|
2
|
+
import { fail } from './data.js';
|
|
3
|
+
import { integer } from './validate.js';
|
|
4
|
+
|
|
5
|
+
const xml = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
|
6
|
+
const number = (n) => Number(n.toPrecision(5)).toString();
|
|
7
|
+
|
|
8
|
+
function runs(points) {
|
|
9
|
+
const output = []; let current = [];
|
|
10
|
+
for (const p of points) { if (p === null) { if (current.length) output.push(current); current = []; } else current.push(p); }
|
|
11
|
+
if (current.length) output.push(current);
|
|
12
|
+
return output;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function plot(value, options) {
|
|
16
|
+
const { artifact: a, history: fullHistory, context, quantiles, i, j } = selectForecast(value, options);
|
|
17
|
+
const median = quantiles['0.5'];
|
|
18
|
+
if (!median) fail('Plot requires a returned 0.5 quantile; no median is invented. The saved artifact remains usable.');
|
|
19
|
+
const window = options.history === 'all' ? fullHistory.length : options.history === undefined ? Math.max(24, median.length * 2) : integer(options.history, '--history', 1, 100000);
|
|
20
|
+
const history = fullHistory.slice(-window);
|
|
21
|
+
const omittedHistory = fullHistory.length - history.length;
|
|
22
|
+
const levels = Object.keys(quantiles).map(Number).sort((a, b) => a - b);
|
|
23
|
+
let pairs;
|
|
24
|
+
if ((options.lower === undefined) !== (options.upper === undefined)) fail('Specify --lower and --upper together.');
|
|
25
|
+
if (options.lower !== undefined) {
|
|
26
|
+
const low = Number(options.lower), high = Number(options.upper);
|
|
27
|
+
if (!(low > 0 && low < high && high < 1) || !quantiles[low] || !quantiles[high]) fail('Band levels must exist in the saved result and satisfy 0 < lower < upper < 1.');
|
|
28
|
+
pairs = [[low, high]];
|
|
29
|
+
} else pairs = levels.filter((q) => q < 0.5).flatMap((low) => { const high = levels.find((q) => Math.abs(q - (1 - low)) < 1e-10); return high === undefined ? [] : [[low, high]]; });
|
|
30
|
+
const all = [...history, ...median, ...pairs.flatMap(([l, u]) => [...quantiles[l], ...quantiles[u]])].filter((v) => v !== null);
|
|
31
|
+
let min = all.reduce((m, v) => Math.min(m, v), Infinity), max = all.reduce((m, v) => Math.max(m, v), -Infinity);
|
|
32
|
+
// Scale first so even extreme finite values cannot overflow the SVG coordinates.
|
|
33
|
+
const scale = Math.max(Math.abs(min), Math.abs(max), 1);
|
|
34
|
+
min /= scale; max /= scale;
|
|
35
|
+
const span = max - min || 1;
|
|
36
|
+
min -= span * 0.08; max += span * 0.08;
|
|
37
|
+
const left = 100, right = 1020, top = 100, bottom = 500;
|
|
38
|
+
const total = history.length + median.length;
|
|
39
|
+
const x = (index) => left + index / Math.max(total - 1, 1) * (right - left);
|
|
40
|
+
const y = (v) => bottom - (v / scale - min) / (max - min) * (bottom - top);
|
|
41
|
+
const line = (points, color) => runs(points).map((r) => r.length === 1 ? `<circle cx="${r[0][0]}" cy="${r[0][1]}" r="3" fill="${color}"/>` : `<polyline points="${r.map((p) => p.join(',')).join(' ')}" fill="none" stroke="${color}" stroke-width="2.5"/>`).join('');
|
|
42
|
+
const boundary = x(history.length - 0.5);
|
|
43
|
+
let missingBands = 0;
|
|
44
|
+
const bands = pairs.map(([low, high], pairIndex) => {
|
|
45
|
+
const points = median.map((_, k) => {
|
|
46
|
+
const l = quantiles[low][k], u = quantiles[high][k];
|
|
47
|
+
if (l === null || u === null || l > u) { missingBands++; return null; }
|
|
48
|
+
return [x(history.length + k), y(l), y(u)];
|
|
49
|
+
});
|
|
50
|
+
return runs(points).map((r) => r.length === 1 ? `<line x1="${r[0][0]}" x2="${r[0][0]}" y1="${r[0][1]}" y2="${r[0][2]}" stroke="#2563eb" stroke-width="5" opacity="0.3"/>` : `<polygon points="${[...r.map((p) => [p[0], p[1]]), ...[...r].reverse().map((p) => [p[0], p[2]])].map((p) => p.join(',')).join(' ')}" fill="#2563eb" opacity="${0.1 + pairIndex * 0.035}"/>`).join('');
|
|
51
|
+
}).join('');
|
|
52
|
+
const labels = context.future_axis.kind === 'timestamps' ? [...context.timestamps.slice(omittedHistory), ...context.future_axis.values] : [...history.map((_, k) => `${k - history.length + 1}`), ...median.map((_, k) => `+${k + 1}`)];
|
|
53
|
+
const ticks = [...new Set([0, Math.max(0, history.length - 1), history.length, total - 1])];
|
|
54
|
+
const bandNames = pairs.map(([l, u]) => `q${l}–q${u} (${number((u - l) * 100)}% nominal mass)`);
|
|
55
|
+
const bandRows = bandNames.length ? Array.from({ length: Math.ceil(bandNames.length / 2) }, (_, k) => bandNames.slice(k * 2, k * 2 + 2).join('; ')) : ['none available'];
|
|
56
|
+
const extraHeight = (bandRows.length - 1) * 20;
|
|
57
|
+
const title = context.target_description || `Forecast — series ${i}, variate ${j}`;
|
|
58
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="${710 + extraHeight}" viewBox="0 0 1100 ${710 + extraHeight}" role="img" aria-labelledby="title desc">
|
|
60
|
+
<title id="title">${xml(title)}</title><desc id="desc">Observed history and saved marginal median forecast. Bands: ${xml(bandNames.join('; ') || 'none available')}. Missing values break lines; crossed or missing bands are omitted. No inference was run.</desc>
|
|
61
|
+
<rect width="1100" height="${710 + extraHeight}" fill="#fff"/>
|
|
62
|
+
<g font-family="system-ui, sans-serif" fill="#172033">
|
|
63
|
+
<text x="100" y="35" font-size="22">${xml(title)}</text>
|
|
64
|
+
<text x="100" y="65" font-size="13">Unit: ${xml(context.unit ?? 'unknown')} · Measurement: ${xml(context.measurement ?? 'unknown')} · Timezone: ${xml(context.timezone ?? 'unspecified')}</text>
|
|
65
|
+
${Array.from({ length: 5 }, (_, k) => { const v = min + k / 4 * (max - min); const yy = bottom - k / 4 * (bottom - top); const label = Number.isFinite(v * scale) ? number(v * scale) : `${number(v)} × ${scale.toExponential(1)}`; return `<line x1="${left}" x2="${right}" y1="${yy}" y2="${yy}" stroke="#e5e7eb"/><text x="90" y="${yy + 4}" text-anchor="end" font-size="12">${xml(label)}</text>`; }).join('')}
|
|
66
|
+
${bands}
|
|
67
|
+
${line(history.map((v, k) => [x(k), y(v)]), '#475569')}
|
|
68
|
+
${line(median.map((v, k) => v === null ? null : [x(history.length + k), y(v)]), '#1d4ed8')}
|
|
69
|
+
<line x1="${boundary}" x2="${boundary}" y1="90" y2="500" stroke="#64748b" stroke-dasharray="5 5"/><text x="${Math.min(boundary + 8, 900)}" y="92" font-size="12">Forecast boundary</text>
|
|
70
|
+
${ticks.map((k, n) => `<text x="${x(k)}" y="${525 + (n % 2) * 18}" text-anchor="${n === 0 ? 'start' : n === ticks.length - 1 ? 'end' : 'middle'}" font-size="11">${xml(labels[k])}</text>`).join('')}
|
|
71
|
+
<text x="100" y="575" font-size="13">${context.future_axis.kind === 'timestamps' ? 'Date/time on the recorded sampling grid' : 'Sampling steps relative to the last observation (0); reliable future dates unavailable'}</text>
|
|
72
|
+
<text x="100" y="602" font-size="13">Observed history (grey) · Marginal median q0.5 (blue)</text>
|
|
73
|
+
${bandRows.map((row, k) => `<text x="100" y="${626 + k * 20}" font-size="12">${k === 0 ? 'Bands: ' : ''}${xml(row)}</text>`).join('')}
|
|
74
|
+
<text x="100" y="${650 + extraHeight}" font-size="12">Nominal quantiles do not establish calibrated coverage. Cumulative intervals and peak probabilities are not shown.</text>
|
|
75
|
+
<text x="100" y="${674 + extraHeight}" font-size="12">History shown: ${history.length} of ${fullHistory.length}; ${omittedHistory} older observations outside view. Full history retained in artifact; model context may differ.</text>
|
|
76
|
+
<text x="100" y="${695 + extraHeight}" font-size="11">Missing median steps: ${median.filter((v) => v === null).length}; omitted band points: ${missingBands}. Use --history all to show every submitted observation.</text>
|
|
77
|
+
</g></svg>\n`;
|
|
78
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { plot } from './plot.js';
|
|
4
|
+
import { CliError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export async function renderPlot(artifact, options) {
|
|
7
|
+
const extension = options.output && options.output !== '-' ? extname(options.output).slice(1).toLowerCase() : '';
|
|
8
|
+
const format = options.format ?? (extension || 'svg');
|
|
9
|
+
if (!['png', 'svg'].includes(format)) throw new CliError(2, 'Plot format must be png or svg.', { option: '--format', expected: 'png|svg', next_action: 'Use --output forecast.png or --output forecast.svg.' });
|
|
10
|
+
if (extension && extension !== format) throw new CliError(2, 'Plot filename extension conflicts with its format.', { option: '--output', expected: `.${format}`, next_action: `Choose a new .${format} output path.` });
|
|
11
|
+
if (format === 'png' && (!options.output || options.output === '-') && process.stdout.isTTY) throw new CliError(2, 'PNG is binary. Choose --output forecast.png or pipe stdout to a file.');
|
|
12
|
+
const svg = plot(artifact, options);
|
|
13
|
+
if (format === 'svg') return svg;
|
|
14
|
+
try {
|
|
15
|
+
const { Resvg } = await import('@resvg/resvg-js');
|
|
16
|
+
return new Resvg(svg, { font: { loadSystemFonts: false, fontFiles: [fileURLToPath(new URL('../assets/DejaVuSans.ttf', import.meta.url))], defaultFontFamily: 'DejaVu Sans', sansSerifFamily: 'DejaVu Sans' } }).render().asPng();
|
|
17
|
+
} catch {
|
|
18
|
+
throw new CliError(8, 'PNG rendering failed. Reinstall the package with its platform dependencies, or use --format svg --output forecast.svg. The saved forecast is unchanged.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir, open } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { prepare, fingerprint } from './artifact.js';
|
|
5
|
+
import { baseUrl } from './client.js';
|
|
6
|
+
import { CliError } from './errors.js';
|
|
7
|
+
import { reserveOutput, output, cleanup } from './io.js';
|
|
8
|
+
|
|
9
|
+
export function readSubmission(value) {
|
|
10
|
+
if (value?.kind !== 'ephemeris.submission' || value.schema_version !== '1') throw new CliError(2, 'Expected an ephemeris.submission v1 receipt. Use the receipt saved before submission.');
|
|
11
|
+
if (typeof value.idempotency_key !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(value.idempotency_key)) throw new CliError(2, 'Receipt has an invalid idempotency key.');
|
|
12
|
+
if (typeof value.origin !== 'string') throw new CliError(2, 'Receipt is missing its service origin.');
|
|
13
|
+
const origin = baseUrl(value.origin);
|
|
14
|
+
const prepared = prepare(value.prepared, {});
|
|
15
|
+
if (fingerprint(prepared.request) !== value.request_sha256) throw new CliError(2, 'Receipt request integrity check failed. Restore the original receipt; do not guess the submitted request.');
|
|
16
|
+
return { prepared, origin, idempotencyKey: value.idempotency_key };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function saveSubmission({ prepared, origin, idempotencyKey, file, outputFile }) {
|
|
20
|
+
let path = file;
|
|
21
|
+
if (!path && outputFile && outputFile !== '-') path = `${outputFile}.submission.json`;
|
|
22
|
+
if (!path) {
|
|
23
|
+
const directory = resolve('.ephemeris', 'submissions');
|
|
24
|
+
try { await mkdir(directory, { recursive: true, mode: 0o700 }); }
|
|
25
|
+
catch { throw new CliError(8, 'Cannot create the receipt directory. Choose a writable --receipt path; no forecast was submitted.'); }
|
|
26
|
+
path = resolve(directory, `${randomUUID()}.json`);
|
|
27
|
+
}
|
|
28
|
+
if (path === '-') throw new CliError(2, '--receipt must be a local file path, not stdout.');
|
|
29
|
+
path = resolve(path);
|
|
30
|
+
const receipt = {
|
|
31
|
+
kind: 'ephemeris.submission', schema_version: '1', created_at: new Date().toISOString(),
|
|
32
|
+
origin, idempotency_key: idempotencyKey, request_sha256: fingerprint(prepared.request), prepared,
|
|
33
|
+
status: 'completion_unknown',
|
|
34
|
+
disclosure: 'Contains full submitted history and local context, never credentials. Written before network access; does not prove submission or completion. Resume uses this origin, request, and key; server retention rules apply.',
|
|
35
|
+
};
|
|
36
|
+
let target;
|
|
37
|
+
try {
|
|
38
|
+
target = await reserveOutput(path);
|
|
39
|
+
await output(`${JSON.stringify(receipt, null, 2)}\n`, target);
|
|
40
|
+
// Persist the directory entry as well as the file before issuing a paid POST.
|
|
41
|
+
if (process.platform !== 'win32') {
|
|
42
|
+
const directory = await open(dirname(path), 'r');
|
|
43
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new CliError(8, 'Cannot persist the submission receipt. No forecast was submitted. Choose a new writable --receipt path.', { receipt_path: path });
|
|
47
|
+
} finally { await cleanup(target); }
|
|
48
|
+
return path;
|
|
49
|
+
}
|
package/src/time.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Conservative calendar semantics: preserve uncertainty instead of guessing dates.
|
|
2
|
+
import { CliError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export function frequency(value) {
|
|
5
|
+
if (typeof value !== 'string') return null;
|
|
6
|
+
if (value === 'D') return { kind: 'day', step: 86400000, label: 'D' };
|
|
7
|
+
if (value === 'W') return { kind: 'day', step: 604800000, label: 'W' };
|
|
8
|
+
if (value === 'MS') return { kind: 'month', label: 'MS' };
|
|
9
|
+
const m = /^(\d+)?(s|min|h|H)$/.exec(value);
|
|
10
|
+
if (!m) return null;
|
|
11
|
+
const step = Number(m[1] || 1) * ({ s: 1000, min: 60000, h: 3600000, H: 3600000 })[m[2]];
|
|
12
|
+
return Number.isSafeInteger(step) && step > 0 ? { kind: 'fixed', step, label: value } : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function checkZone(zone) {
|
|
16
|
+
if (zone == null) return;
|
|
17
|
+
try { new Intl.DateTimeFormat('en', { timeZone: zone }); }
|
|
18
|
+
catch { throw new CliError(2, 'timezone must be a valid IANA zone, such as Australia/Brisbane or UTC.'); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseTime(value) {
|
|
22
|
+
if (typeof value !== 'string') return null;
|
|
23
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})?)?$/.exec(value);
|
|
24
|
+
if (!m || +m[1] < 1000) return null;
|
|
25
|
+
const [year, month, day, hour, minute, second] = m.slice(1, 7).map((x) => Number(x || 0));
|
|
26
|
+
const ms = Number((m[7] || '').padEnd(3, '0'));
|
|
27
|
+
const wall = Date.UTC(year, month - 1, day, hour, minute, second, ms);
|
|
28
|
+
const d = new Date(wall);
|
|
29
|
+
if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return null;
|
|
30
|
+
const offset = m[8];
|
|
31
|
+
if (offset && offset !== 'Z' && (+offset.slice(1, 3) > 23 || +offset.slice(4) > 59)) return null;
|
|
32
|
+
const offsetMs = !offset || offset === 'Z' ? 0 : (offset[0] === '-' ? -1 : 1) * (+offset.slice(1, 3) * 60 + +offset.slice(4)) * 60000;
|
|
33
|
+
return { raw: value, wall, instant: wall - offsetMs, offset: offset || null, kind: !m[4] ? 'date' : offset ? 'aware' : 'naive', year, month, day, clock: value.slice(10).replace(/(Z|[+-]\d{2}:\d{2})$/, '') };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function zoneMatches(p, zone) {
|
|
37
|
+
if (!zone || p.kind !== 'aware') return true;
|
|
38
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(p.instant).map((v) => [v.type, v.value]));
|
|
39
|
+
return +parts.year === p.year && +parts.month === p.month && +parts.day === p.day && +parts.hour === new Date(p.wall).getUTCHours() && +parts.minute === new Date(p.wall).getUTCMinutes();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function timeEvidence(timestamps, declared = null, zone = null) {
|
|
43
|
+
checkZone(zone);
|
|
44
|
+
const unknown = { supplied_frequency: declared, candidate_frequency: null, effective_frequency: declared, basis: 'unestablished', invalid_timestamps: 0, duplicate_timestamps: 0, out_of_order_pairs: 0, missing_periods: null, off_grid_intervals: null, coverage: { start: null, end: null }, regular: false, reason: null };
|
|
45
|
+
if (timestamps == null) return { ...unknown, basis: declared ? 'caller_declared_without_timestamps' : 'unknown', reason: 'No timestamps supplied; coverage and missing periods are unknown.' };
|
|
46
|
+
const parsed = timestamps.map(parseTime);
|
|
47
|
+
const valid = parsed.filter(Boolean);
|
|
48
|
+
const bad = parsed.length - valid.length;
|
|
49
|
+
const mixed = new Set(valid.map((p) => p.kind)).size > 1;
|
|
50
|
+
const mismatched = valid.some((p) => !zoneMatches(p, zone));
|
|
51
|
+
const ord = (p) => p.kind === 'aware' ? p.instant : p.wall;
|
|
52
|
+
const sorted = [...valid].sort((a, b) => ord(a) - ord(b));
|
|
53
|
+
const duplicates = valid.length - new Set(valid.map(ord)).size;
|
|
54
|
+
const inversions = valid.slice(1).filter((p, i) => ord(p) < ord(valid[i])).length;
|
|
55
|
+
const base = { ...unknown, invalid_timestamps: bad, duplicate_timestamps: duplicates, out_of_order_pairs: inversions, coverage: { start: sorted[0]?.raw ?? null, end: sorted.at(-1)?.raw ?? null } };
|
|
56
|
+
if (bad || mixed || mismatched || duplicates || inversions || parsed.length < 2) return { ...base, reason: 'Need at least two valid, consistently aware, zone-consistent, unique timestamps in increasing source order.' };
|
|
57
|
+
const sameClock = valid.every((p) => p.clock === valid[0].clock);
|
|
58
|
+
const wallDiffs = valid.slice(1).map((p, i) => p.wall - valid[i].wall);
|
|
59
|
+
const diffs = valid.slice(1).map((p, i) => ord(p) - ord(valid[i]));
|
|
60
|
+
const months = valid.slice(1).map((p, i) => (p.year - valid[i].year) * 12 + p.month - valid[i].month);
|
|
61
|
+
let candidate = null;
|
|
62
|
+
if (valid.every((p) => p.day === 1) && sameClock && months.every((n) => n === 1)) candidate = 'MS';
|
|
63
|
+
else if (sameClock && wallDiffs.every((n) => n === 86400000)) candidate = 'D';
|
|
64
|
+
else if (sameClock && wallDiffs.every((n) => n === 604800000)) candidate = 'W';
|
|
65
|
+
else if (diffs.every((n) => n === diffs[0]) && diffs[0] > 0 && diffs[0] < 86400000 && diffs[0] % 1000 === 0 && valid[0].kind !== 'date') candidate = diffs[0] % 3600000 === 0 ? `${diffs[0] / 3600000}h` : diffs[0] % 60000 === 0 ? `${diffs[0] / 60000}min` : `${diffs[0] / 1000}s`;
|
|
66
|
+
const effective = declared || candidate;
|
|
67
|
+
const f = frequency(effective);
|
|
68
|
+
const evidence = { ...base, candidate_frequency: candidate, effective_frequency: effective, basis: declared ? 'caller_declared_grid' : candidate ? 'observed_regular_grid_candidate' : 'unestablished' };
|
|
69
|
+
if (!f) return { ...evidence, reason: 'Unsupported or ambiguous grid; missing periods cannot be measured. Supported: whole-second subdaily, D, W, MS.' };
|
|
70
|
+
if (f.kind === 'fixed' && (valid[0].kind === 'date' || (valid[0].kind === 'naive' && zone && zone !== 'UTC'))) return { ...evidence, reason: 'Subdaily named-zone times need explicit offsets to resolve daylight-saving ambiguity.' };
|
|
71
|
+
const steps = f.kind === 'month' ? months : (f.kind === 'day' ? wallDiffs : diffs).map((n) => n / f.step);
|
|
72
|
+
const offGrid = steps.filter((n) => !Number.isInteger(n) || n < 1).length + ((f.kind === 'month' && (!sameClock || valid.some((p) => p.day !== 1))) || (f.kind === 'day' && !sameClock) ? 1 : 0);
|
|
73
|
+
const missing = offGrid ? null : steps.reduce((n, step) => n + step - 1, 0);
|
|
74
|
+
return { ...evidence, missing_periods: missing, off_grid_intervals: offGrid, regular: offGrid === 0 && missing === 0, reason: !declared ? 'Frequency is evidence from the observed window, not proof of the intended calendar.' : null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function futureAxis(timestamps, freq, zone, horizon) {
|
|
78
|
+
const fallback = (reason) => ({ kind: 'steps', values: Array.from({ length: horizon }, (_, i) => i + 1), reason });
|
|
79
|
+
const ev = timeEvidence(timestamps, freq, zone);
|
|
80
|
+
if (!ev.regular) return fallback(ev.reason || 'History does not establish a complete regular time grid.');
|
|
81
|
+
const p = parseTime(timestamps.at(-1));
|
|
82
|
+
const f = frequency(ev.effective_frequency);
|
|
83
|
+
if (p.kind === 'aware' && f.kind !== 'fixed' && ((zone && zone !== 'UTC') || (!zone && p.offset !== 'Z'))) return fallback('Calendar projection needs verified future timezone offsets; using forecast steps. Supply a validated future timestamp grid when known.');
|
|
84
|
+
const values = [];
|
|
85
|
+
for (let i = 1; i <= horizon; i++) {
|
|
86
|
+
const wall = f.kind === 'month' ? Date.UTC(p.year, p.month - 1 + i, 1, new Date(p.wall).getUTCHours(), new Date(p.wall).getUTCMinutes(), new Date(p.wall).getUTCSeconds(), new Date(p.wall).getUTCMilliseconds()) : p.wall + i * f.step;
|
|
87
|
+
if (!Number.isFinite(wall) || Math.abs(wall) > 8640000000000000) return fallback('Dates exceed the supported calendar range.');
|
|
88
|
+
const iso = new Date(wall).toISOString();
|
|
89
|
+
if (!/^\d{4}-/.test(iso)) return fallback('Projected dates exceed the supported four-digit year range.');
|
|
90
|
+
if (p.kind === 'date') values.push(iso.slice(0, 10));
|
|
91
|
+
else if (f.kind === 'fixed' && p.kind === 'aware') values.push(new Date(p.instant + i * f.step).toISOString());
|
|
92
|
+
else values.push(iso.slice(0, -1) + (p.offset || ''));
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'timestamps', values, reason: 'Projected from the recorded regular grid; future observations have not occurred.' };
|
|
95
|
+
}
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CliError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
4
|
+
const fail = (message, details) => { throw new CliError(2, message, details); };
|
|
5
|
+
export function integer(v, name, min, max) {
|
|
6
|
+
const details = { [name.startsWith('--') ? 'option' : 'field']: name, expected: `integer ${min}–${max}`, next_action: `Supply ${name} within the stated range. Use the command --help for units and defaults.` };
|
|
7
|
+
if (typeof v === 'string' && !/^\d+$/.test(v)) fail(`${name} must be an integer ${min}–${max}.`, details);
|
|
8
|
+
const n = typeof v === 'string' ? Number(v) : v;
|
|
9
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) fail(`${name} must be an integer ${min}–${max}.`, details);
|
|
10
|
+
return n;
|
|
11
|
+
}
|
|
12
|
+
function numericPath(v, name, length) {
|
|
13
|
+
if (!Array.isArray(v) || !v.length || !v.every((x) => typeof x === 'number' && Number.isFinite(x))) {
|
|
14
|
+
fail(`${name} must be a non-empty array of finite numbers.`);
|
|
15
|
+
}
|
|
16
|
+
if (length !== undefined && v.length !== length) fail(`${name} must contain ${length} values.`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateForecast(body) {
|
|
20
|
+
if (!object(body)) fail('Input must be a JSON request object. See ephemeris forecast --help.');
|
|
21
|
+
if (!['route', 'ensemble', 'explicit'].includes(body.mode)) fail('mode must be route, ensemble, or explicit.');
|
|
22
|
+
if (body.mode === 'explicit' && (typeof body.model !== 'string' || !body.model.trim())) fail('explicit mode requires model; discover names with ephemeris models list.');
|
|
23
|
+
if (body.model !== undefined && (typeof body.model !== 'string' || !body.model.trim())) fail('model must be a non-empty string.');
|
|
24
|
+
if (body.freq !== undefined) fail('Put freq inside each series object: series[0].freq, not at the request root.');
|
|
25
|
+
if (!Array.isArray(body.series) || !body.series.length || body.series.length > 64) fail('series must contain 1–64 objects with values and optional freq.');
|
|
26
|
+
for (const field of ['horizon', 'context_len', 'top_k']) {
|
|
27
|
+
if (body[field] !== undefined && body[field] !== null && typeof body[field] !== 'number') fail(`${field} must be a JSON number, not a string.`);
|
|
28
|
+
}
|
|
29
|
+
const horizon = integer(body.horizon ?? 64, 'horizon', 1, 4096);
|
|
30
|
+
if (body.horizon === null || body.context_len === null) fail('horizon and context_len must be integers when provided.');
|
|
31
|
+
integer(body.context_len ?? 256, 'context_len', 1, 16384);
|
|
32
|
+
let slots = 0;
|
|
33
|
+
for (const [i, entry] of body.series.entries()) {
|
|
34
|
+
const name = `series[${i}]`;
|
|
35
|
+
if (!object(entry)) fail(`${name} must be an object: {"values":[...],"freq":"H"}.`);
|
|
36
|
+
if (!Array.isArray(entry.values) || !entry.values.length) fail(`${name}.values must be a non-empty array.`);
|
|
37
|
+
const paths = Array.isArray(entry.values[0]) ? entry.values : [entry.values];
|
|
38
|
+
for (const path of paths) numericPath(path, `${name}.values`, paths[0].length);
|
|
39
|
+
slots += paths.length;
|
|
40
|
+
if (entry.freq !== undefined && (typeof entry.freq !== 'string' || !entry.freq.trim())) fail(`${name}.freq must be a non-empty string.`);
|
|
41
|
+
if (entry.covariates !== undefined) {
|
|
42
|
+
const cov = entry.covariates;
|
|
43
|
+
if (!object(cov) || !object(cov.past)) fail(`${name}.covariates needs a past object.`);
|
|
44
|
+
for (const side of ['past', 'future']) {
|
|
45
|
+
if (side === 'future' && cov.future === undefined) continue;
|
|
46
|
+
if (!object(cov[side]) || Object.keys(cov[side]).length > 16) fail(`${name}.covariates.${side} must have at most 16 channels.`);
|
|
47
|
+
for (const [channel, values] of Object.entries(cov[side])) {
|
|
48
|
+
if (!channel.trim()) fail('Covariate names must be non-empty.');
|
|
49
|
+
numericPath(values, `${name}.covariates.${side}.${channel}`, side === 'past' ? paths[0].length : horizon);
|
|
50
|
+
if (side === 'future' && !Object.hasOwn(cov.past, channel)) fail(`Future covariate ${channel} needs matching past history.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (slots > 256) fail('Request exceeds 256 total variates.');
|
|
56
|
+
if (body.quantiles !== undefined) {
|
|
57
|
+
const q = body.quantiles;
|
|
58
|
+
if (!Array.isArray(q) || !q.length || q.length > 21 || q.some((v) => typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || v >= 1) || new Set(q).size !== q.length) {
|
|
59
|
+
fail('quantiles must contain 1–21 unique numbers strictly between 0 and 1; use [0.1,0.5,0.9] for a central 80% interval.', { field: '/quantiles', expected: '1–21 unique JSON numbers strictly between 0 and 1', next_action: 'Correct the requested levels and run forecast run --dry-run before submitting. For median and a central nominal 80% interval use --quantiles 0.1,0.5,0.9.' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (slots * horizon * (body.quantiles?.length ?? 21) > 120000) fail('Total variates × horizon × quantile count must not exceed 120000 (omitted quantiles count as 21). Reduce request dimensions explicitly; do not silently truncate data.', { field: '/series', next_action: 'Reduce the requested horizon/quantiles or explicitly partition series, then dry-run again.' });
|
|
63
|
+
if (body.top_k !== undefined && body.top_k !== null) {
|
|
64
|
+
if (body.mode !== 'ensemble') fail('top_k requires ensemble mode.');
|
|
65
|
+
integer(body.top_k, 'top_k', 1, 16);
|
|
66
|
+
}
|
|
67
|
+
if (body.combine !== undefined && !['mixture', 'vincentize'].includes(body.combine)) fail('combine must be mixture or vincentize.');
|
|
68
|
+
return body;
|
|
69
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
3
|
+
export const VERSION = pkg.version;
|
|
4
|
+
export function versionInfo() {
|
|
5
|
+
let commit = null;
|
|
6
|
+
try {
|
|
7
|
+
const build = JSON.parse(readFileSync(new URL('../build-info.json', import.meta.url), 'utf8'));
|
|
8
|
+
if (build.version === VERSION && /^[a-f0-9]{40}$/.test(build.commit)) commit = build.commit;
|
|
9
|
+
} catch { /* Source checkouts have no stamped build; do not guess. */ }
|
|
10
|
+
return {
|
|
11
|
+
component: 'ephemeris-cli', version: VERSION, build_commit: commit,
|
|
12
|
+
artifact_schema_versions: {
|
|
13
|
+
'ephemeris.forecast': ['1'], 'ephemeris.data': ['1'],
|
|
14
|
+
'ephemeris.request': ['1'], 'ephemeris.submission': ['1'],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|