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/artifact.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hash, isObject, fail, metadata, validateSnapshot } from './data.js';
|
|
3
|
+
import { integer, validateForecast } from './validate.js';
|
|
4
|
+
import { timeEvidence, futureAxis } from './time.js';
|
|
5
|
+
|
|
6
|
+
export function canonical(value) {
|
|
7
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
8
|
+
if (isObject(value)) return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function fingerprint(value) { return hash(canonical(value)); }
|
|
13
|
+
|
|
14
|
+
export function contextFor(request, supplied = {}) {
|
|
15
|
+
if (!isObject(supplied) || Object.keys(supplied).some((k) => !['series', 'scenario_assumptions', 'snapshot_id', 'source'].includes(k))) fail('Local context accepts series, scenario_assumptions, snapshot_id, and source only.');
|
|
16
|
+
if (supplied.series !== undefined && (!Array.isArray(supplied.series) || supplied.series.length !== request.series.length)) fail('context.series must align one-to-one with request.series.');
|
|
17
|
+
if (supplied.scenario_assumptions !== undefined && (!Array.isArray(supplied.scenario_assumptions) || supplied.scenario_assumptions.some((v) => typeof v !== 'string' || !v.trim()))) fail('scenario_assumptions must be a list of caller-supplied statements.');
|
|
18
|
+
const horizon = request.horizon ?? 64;
|
|
19
|
+
const series = request.series.map((s, i) => {
|
|
20
|
+
const c = supplied.series?.[i] ?? {};
|
|
21
|
+
if (!isObject(c)) fail('Each context.series entry must be an object.');
|
|
22
|
+
const allowed = ['series_id', 'unit', 'timezone', 'target_description', 'measurement', 'timestamps', 'future_timestamps', 'future_covariate_roles', 'source_available_at', 'recorded_as_of'];
|
|
23
|
+
if (Object.keys(c).some((k) => !allowed.includes(k))) fail('Unknown local series context field. See forecast run --help.');
|
|
24
|
+
const info = metadata({ unit: c.unit, timezone: c.timezone, 'target-description': c.target_description, measurement: c.measurement });
|
|
25
|
+
for (const field of ['series_id', 'source_available_at', 'recorded_as_of']) if (c[field] != null && (typeof c[field] !== 'string' || !c[field].trim())) fail(`${field} must be a string or null.`);
|
|
26
|
+
const history = Array.isArray(s.values[0]) ? s.values[0] : s.values;
|
|
27
|
+
const timestamps = c.timestamps ?? null;
|
|
28
|
+
if (timestamps !== null && (!Array.isArray(timestamps) || timestamps.length !== history.length)) fail('context timestamps must align with every supplied history point.');
|
|
29
|
+
const evidence = timeEvidence(timestamps, s.freq ?? null, info.timezone);
|
|
30
|
+
if (timestamps !== null && (evidence.invalid_timestamps || evidence.duplicate_timestamps || evidence.out_of_order_pairs || !evidence.regular)) fail('Context timestamps must form a complete supported grid matching the request frequency. Otherwise omit timestamps and use forecast steps.');
|
|
31
|
+
let axis = futureAxis(timestamps, s.freq ?? null, info.timezone, horizon);
|
|
32
|
+
if (c.future_timestamps != null) {
|
|
33
|
+
if (!timestamps || !Array.isArray(c.future_timestamps) || c.future_timestamps.length !== horizon) fail('Explicit future_timestamps require history timestamps and exactly horizon entries.');
|
|
34
|
+
const combined = timeEvidence([...timestamps, ...c.future_timestamps], s.freq ?? evidence.effective_frequency, info.timezone);
|
|
35
|
+
if (!combined.regular) fail('future_timestamps must continue the history grid without gaps, duplicates, or timezone changes.');
|
|
36
|
+
axis = { kind: 'timestamps', values: c.future_timestamps, reason: 'Caller-supplied future grid validated against the history grid.' };
|
|
37
|
+
}
|
|
38
|
+
const roles = c.future_covariate_roles ?? {};
|
|
39
|
+
if (!isObject(roles) || Object.keys(roles).some((n) => !Object.hasOwn(s.covariates?.future ?? {}, n)) || Object.values(roles).some((v) => !['known_future', 'scenario_assumption', 'unspecified'].includes(v))) fail('future_covariate_roles must map submitted future channel names to known_future, scenario_assumption, or unspecified.');
|
|
40
|
+
return { ...info, series_id: c.series_id ?? null, timestamps, future_axis: axis, frequency_evidence: evidence,
|
|
41
|
+
source_available_at: c.source_available_at ?? null, recorded_as_of: c.recorded_as_of ?? null,
|
|
42
|
+
future_covariate_roles: Object.fromEntries(Object.keys(s.covariates?.future ?? {}).map((name) => [name, roles[name] ?? 'unspecified'])) };
|
|
43
|
+
});
|
|
44
|
+
return { series, scenario_assumptions: supplied.scenario_assumptions ?? [], snapshot_id: supplied.snapshot_id ?? null, source: supplied.source ?? null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function prepare(input, options, externalContext) {
|
|
48
|
+
let request, context;
|
|
49
|
+
if (input?.kind === 'ephemeris.data') {
|
|
50
|
+
// Verify the frozen snapshot, then validate the selected series only.
|
|
51
|
+
if (input.schema_version !== '1' || !Array.isArray(input.series) || input.snapshot_id !== hash(JSON.stringify(input.series))) fail('Snapshot version or integrity check failed.');
|
|
52
|
+
if (input.series.length > 1 && !options.series) fail('Choose --series ID from the inspection; the CLI does not guess a target.');
|
|
53
|
+
const chosen = options.series ? input.series.find((s) => s.series_id === options.series) : input.series[0];
|
|
54
|
+
if (!chosen) fail('Selected series was not found in the inspected snapshot.');
|
|
55
|
+
validateSnapshot({ ...input, series: [chosen], snapshot_id: hash(JSON.stringify([chosen])) });
|
|
56
|
+
if (externalContext) fail('An inspected snapshot already fixes its context. Reinspect the source to change metadata.');
|
|
57
|
+
request = { mode: 'route', series: [{ values: chosen.values, ...(chosen.context.frequency ? { freq: chosen.context.frequency } : {}) }] };
|
|
58
|
+
const { frequency: _frequency, ...local } = chosen.context;
|
|
59
|
+
context = { series: [{ ...local, series_id: chosen.series_id, timestamps: chosen.timestamps }], snapshot_id: input.snapshot_id, source: input.source };
|
|
60
|
+
} else if (input?.kind === 'ephemeris.request') {
|
|
61
|
+
if (input.schema_version !== '1') fail('Unsupported prepared-request version.');
|
|
62
|
+
if (externalContext) fail('Prepared requests already include context. Edit it explicitly before rerunning.');
|
|
63
|
+
request = structuredClone(input.request); context = input.context;
|
|
64
|
+
} else { request = structuredClone(input); context = externalContext; }
|
|
65
|
+
if (!isObject(request)) fail('Input must be a public API request, prepared request, or inspected snapshot.');
|
|
66
|
+
if (options.series && input?.kind !== 'ephemeris.data') fail('--series applies only to an inspected snapshot.');
|
|
67
|
+
for (const key of ['mode', 'model']) if (options[key] !== undefined) request[key] = options[key];
|
|
68
|
+
if (options.horizon !== undefined) request.horizon = integer(options.horizon, '--horizon', 1, 4096);
|
|
69
|
+
if (options['context-len'] !== undefined) request.context_len = integer(options['context-len'], '--context-len', 1, 16384);
|
|
70
|
+
if (options.quantiles !== undefined) {
|
|
71
|
+
if (!/^\s*\d*\.?\d+(?:\s*,\s*\d*\.?\d+)*\s*$/.test(options.quantiles)) fail('--quantiles must be comma-separated numbers, e.g. 0.1,0.5,0.9.');
|
|
72
|
+
request.quantiles = options.quantiles.split(',').map(Number);
|
|
73
|
+
}
|
|
74
|
+
validateForecast(request);
|
|
75
|
+
// Keep metadata and credentials out of the API even if the gateway permits extras.
|
|
76
|
+
const rootKeys = ['mode', 'model', 'series', 'horizon', 'quantiles', 'context_len', 'top_k', 'combine'];
|
|
77
|
+
if (Object.keys(request).some((k) => !rootKeys.includes(k))) fail('Unknown request field. Local metadata belongs in --context, not in the API request.');
|
|
78
|
+
for (const s of request.series) if (Object.keys(s).some((k) => !['values', 'freq', 'covariates'].includes(k))) fail('Unknown series field. Put units, timestamps, and descriptions in --context.');
|
|
79
|
+
// A prepared request stores only supplied context, so it can be reused unchanged.
|
|
80
|
+
const resolved = contextFor(request, context);
|
|
81
|
+
const local = { series: resolved.series.map(({ future_axis, frequency_evidence, ...s }) => s), scenario_assumptions: resolved.scenario_assumptions, snapshot_id: resolved.snapshot_id, source: resolved.source };
|
|
82
|
+
// Explicit future timestamps must survive a dry-run/retry round trip.
|
|
83
|
+
local.series.forEach((s, i) => { if (context?.series?.[i]?.future_timestamps) s.future_timestamps = context.series[i].future_timestamps; });
|
|
84
|
+
return { kind: 'ephemeris.request', schema_version: '1', request, context: local, disclosure: 'Includes the full submitted input history. Context is local and is not sent to the API.' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createArtifact(prepared, response, transport) {
|
|
88
|
+
return { kind: 'ephemeris.forecast', schema_version: '1', artifact_id: randomUUID(), created_at: new Date().toISOString(),
|
|
89
|
+
request: prepared.request, request_sha256: fingerprint(prepared.request), response,
|
|
90
|
+
context: contextFor(prepared.request, prepared.context), transport,
|
|
91
|
+
disclosure: { input_history_included: true, context_sent_to_api: false, credentials_included: false, provider_input_note: 'All submitted history is saved. The service may truncate it using context_len and model context limits.' },
|
|
92
|
+
interoperability: { gnomon_import: 'not_implemented', recorded_in_ledger: false, identity_basis: 'local artifact UUID and request SHA-256; hashes are not authenticity signatures' } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readArtifact(value) {
|
|
96
|
+
if (value?.kind !== 'ephemeris.forecast' || value.schema_version !== '1') fail('Expected a version 1 Ephemeris forecast artifact from forecast run.');
|
|
97
|
+
validateForecast(value.request);
|
|
98
|
+
if (fingerprint(value.request) !== value.request_sha256) fail('Artifact request integrity check failed.');
|
|
99
|
+
if (!isObject(value.context) || !Array.isArray(value.context.series) || value.context.series.length !== value.request.series.length) fail('Artifact context must align with the request.');
|
|
100
|
+
// Validate serialized context again; do not trust cached axes in a hand-edited file.
|
|
101
|
+
const supplied = { ...value.context, series: value.context.series.map(({ future_axis, frequency_evidence, ...s }) => ({ ...s, ...(future_axis?.kind === 'timestamps' ? { future_timestamps: future_axis.values } : {}) })) };
|
|
102
|
+
const context = contextFor(value.request, supplied);
|
|
103
|
+
const rows = value.response?.forecasts;
|
|
104
|
+
if (!Array.isArray(rows) || rows.length !== value.request.series.length) fail('Saved response has malformed/missing forecast rows. The original artifact remains intact; no inference is repeated.');
|
|
105
|
+
const horizon = value.request.horizon ?? 64;
|
|
106
|
+
rows.forEach((row, i) => {
|
|
107
|
+
if (!isObject(row?.quantiles) || !Object.keys(row.quantiles).length) fail('Saved forecast has no quantile arrays.');
|
|
108
|
+
const multivariate = Array.isArray(value.request.series[i].values[0]);
|
|
109
|
+
const count = multivariate ? value.request.series[i].values.length : 1;
|
|
110
|
+
const levels = Object.keys(row.quantiles).map(Number);
|
|
111
|
+
if (levels.some((q) => !Number.isFinite(q) || q <= 0 || q >= 1) || new Set(levels).size !== levels.length) fail('Saved forecast has invalid or duplicate quantile levels.');
|
|
112
|
+
for (const array of Object.values(row.quantiles)) {
|
|
113
|
+
const paths = multivariate ? array : [array];
|
|
114
|
+
if (!Array.isArray(paths) || paths.length !== count || paths.some((p) => !Array.isArray(p) || p.length !== horizon || p.some((v) => v !== null && (typeof v !== 'number' || !Number.isFinite(v))))) fail('Saved forecast shape does not match horizon and variates; null denotes missing output.');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return { ...value, context };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function selectForecast(artifact, options) {
|
|
121
|
+
const a = readArtifact(artifact);
|
|
122
|
+
if (a.request.series.length > 1 && options['series-index'] === undefined) fail('Choose --series-index for a multi-series artifact.');
|
|
123
|
+
const i = integer(options['series-index'] ?? '0', '--series-index', 0, a.request.series.length - 1);
|
|
124
|
+
const values = a.request.series[i].values;
|
|
125
|
+
const multi = Array.isArray(values[0]);
|
|
126
|
+
if (multi && values.length > 1 && options['variate-index'] === undefined) fail('Choose --variate-index for a multivariate artifact.');
|
|
127
|
+
const j = integer(options['variate-index'] ?? '0', '--variate-index', 0, multi ? values.length - 1 : 0);
|
|
128
|
+
return { artifact: a, i, j, history: multi ? values[j] : values, context: a.context.series[i],
|
|
129
|
+
quantiles: Object.fromEntries(Object.entries(a.response.forecasts[i].quantiles).map(([q, array]) => [Number(q), multi ? array[j] : array])),
|
|
130
|
+
pointer: `/response/forecasts/${i}/quantiles`, history_pointer: `/request/series/${i}/values${multi ? `/${j}` : ''}` };
|
|
131
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { versionInfo } from './version.js';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { commands, common, help, VERSION } from './commands.js';
|
|
5
|
+
import { CliError } from './errors.js';
|
|
6
|
+
import { baseUrl, request } from './client.js';
|
|
7
|
+
import { integer } from './validate.js';
|
|
8
|
+
import { inspect } from './data.js';
|
|
9
|
+
import { prepare, createArtifact } from './artifact.js';
|
|
10
|
+
import { describe } from './describe.js';
|
|
11
|
+
import { renderPlot } from './render.js';
|
|
12
|
+
import { argumentError } from './arguments.js';
|
|
13
|
+
import { readSubmission, saveSubmission } from './submission.js';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import { readText, parseJson, sanitize, assertNoSecrets, reserveOutput, output, cleanup, forecastSchema } from './io.js';
|
|
16
|
+
|
|
17
|
+
const definitions = Object.assign({}, common, ...Object.values(commands).map((c) => c.options), { version: { type: 'boolean' } });
|
|
18
|
+
const options = Object.fromEntries(Object.entries(definitions).map(([k, v]) => [k, { type: v.type, ...(v.short ? { short: v.short } : {}) }]));
|
|
19
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
20
|
+
|
|
21
|
+
export async function main(argv, env = process.env) {
|
|
22
|
+
const key = (env.EPHEMERIS_API_KEY || '').trim();
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const interrupt = () => controller.abort();
|
|
25
|
+
let target, idempotencyKey, paidArtifact, receiptPath, resumedOrigin;
|
|
26
|
+
const log = (data) => process.stderr.write(`${JSON.stringify(sanitize(data, key))}\n`);
|
|
27
|
+
try {
|
|
28
|
+
let parsed;
|
|
29
|
+
try { parsed = parseArgs({ args: argv, options, allowPositionals: true, strict: true, tokens: true }); }
|
|
30
|
+
catch (error) { throw argumentError(error, argv, options); }
|
|
31
|
+
const { values, positionals, tokens } = parsed;
|
|
32
|
+
assertNoSecrets(values, key);
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (const token of tokens.filter((t) => t.kind === 'option')) { if (seen.has(token.name)) throw new CliError(2, 'Repeated options are not supported. Supply each option once.'); seen.add(token.name); }
|
|
35
|
+
if (values.version && !positionals.length) { await output(`${VERSION}\n`); return 0; }
|
|
36
|
+
let name = positionals.join(' ');
|
|
37
|
+
if (name === 'forecast' && (values.input !== undefined || values.resume !== undefined)) name = 'forecast run';
|
|
38
|
+
if (!name && (values.help || !argv.length)) { await output(help()); return 0; }
|
|
39
|
+
if (!commands[name] && ['models', 'usage', 'auth', 'schema', 'data', 'forecast'].includes(name)) {
|
|
40
|
+
if (!values.help && Object.keys(values).length) throw new CliError(2, `Choose a subcommand. Run ephemeris ${name} --help.`);
|
|
41
|
+
await output(`Usage: ephemeris ${name} <command> [options]\n\n${Object.keys(commands).filter((n) => n.startsWith(`${name} `)).map((n) => ` ${n} — ${commands[n].summary}\n Run ephemeris ${n} --help`).join('\n')}\n`);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
const command = commands[name];
|
|
45
|
+
if (!command) throw new CliError(2, 'Unknown or missing command. Run ephemeris --help.');
|
|
46
|
+
for (const option of Object.keys(values)) if (!Object.hasOwn(common, option) && !Object.hasOwn(command.options, option)) throw new CliError(2, `--${option} is not supported for ${name}.`, { option: `--${option}`, next_action: `Run ephemeris ${name} --help.` });
|
|
47
|
+
if (values.help) { await output(help(name)); return 0; }
|
|
48
|
+
let data, textResult, body, prepared, path = command.path;
|
|
49
|
+
if (['data inspect', 'forecast run', 'forecast describe', 'forecast plot'].includes(name)) {
|
|
50
|
+
if (name === 'forecast run' && values.resume !== undefined) {
|
|
51
|
+
const forbidden = ['input', 'context', 'series', 'mode', 'model', 'horizon', 'quantiles', 'context-len', 'idempotency-key', 'base-url', 'receipt'];
|
|
52
|
+
const conflict = forbidden.find((option) => values[option] !== undefined);
|
|
53
|
+
if (conflict) throw new CliError(2, '--resume fixes the saved origin, request, context, and key; overrides are not allowed.', { option: `--${conflict}`, next_action: 'Use --resume alone with --output, --timeout, or --dry-run.' });
|
|
54
|
+
if (values.resume === '-') throw new CliError(2, '--resume requires a saved receipt file path.');
|
|
55
|
+
}
|
|
56
|
+
const text = await readText(values.resume ?? values.input);
|
|
57
|
+
assertNoSecrets(text, key);
|
|
58
|
+
if (name === 'data inspect') data = inspect(text, values, values.input);
|
|
59
|
+
else {
|
|
60
|
+
const input = parseJson(text); assertNoSecrets(input, key);
|
|
61
|
+
if (name === 'forecast run') {
|
|
62
|
+
if (values.context === '-' && values.input === '-') throw new CliError(2, 'Only one input may use stdin.');
|
|
63
|
+
const context = values.context ? parseJson(await readText(values.context)) : undefined;
|
|
64
|
+
assertNoSecrets(context, key);
|
|
65
|
+
if (values.resume !== undefined) {
|
|
66
|
+
const saved = readSubmission(input);
|
|
67
|
+
prepared = saved.prepared; resumedOrigin = saved.origin; idempotencyKey = saved.idempotencyKey;
|
|
68
|
+
receiptPath = resolve(values.resume);
|
|
69
|
+
} else {
|
|
70
|
+
prepared = prepare(input, values, context);
|
|
71
|
+
idempotencyKey = values['idempotency-key'] ?? randomUUID();
|
|
72
|
+
}
|
|
73
|
+
body = prepared.request;
|
|
74
|
+
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) throw new CliError(2, '--idempotency-key must be 8–128 letters, digits, dots, underscores, colons, or hyphens.');
|
|
75
|
+
assertNoSecrets(idempotencyKey, key);
|
|
76
|
+
if (values['dry-run']) data = prepared;
|
|
77
|
+
} else if (name === 'forecast describe') data = describe(input, values);
|
|
78
|
+
else textResult = await renderPlot(input, values);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (name === 'version') data = versionInfo();
|
|
82
|
+
if (name === 'schema forecast') data = await forecastSchema(values.kind);
|
|
83
|
+
if (name === 'usage list') path += `?limit=${integer(values.limit ?? '50', '--limit', 1, 200)}&offset=${integer(values.offset ?? '0', '--offset', 0, Number.MAX_SAFE_INTEGER)}`;
|
|
84
|
+
const needsNetwork = data === undefined && textResult === undefined && !(name === 'auth status' && !values.verify);
|
|
85
|
+
let origin, timeout;
|
|
86
|
+
if (needsNetwork || name === 'auth status') {
|
|
87
|
+
origin = baseUrl(resumedOrigin ?? values['base-url'] ?? env.EPHEMERIS_BASE_URL);
|
|
88
|
+
timeout = integer(values.timeout ?? '300', '--timeout', 1, 3600);
|
|
89
|
+
if (needsNetwork && (!key || /\s/.test(key))) throw new CliError(3, 'Set EPHEMERIS_API_KEY to your dashboard API key. Run ephemeris auth status --help.');
|
|
90
|
+
if (!needsNetwork) data = { configured: Boolean(key), verified: false, source: key ? 'EPHEMERIS_API_KEY' : null, base_url: origin };
|
|
91
|
+
}
|
|
92
|
+
target = await reserveOutput(values.output);
|
|
93
|
+
if (needsNetwork) {
|
|
94
|
+
process.on('SIGINT', interrupt); process.on('SIGTERM', interrupt);
|
|
95
|
+
if (name === 'forecast run') {
|
|
96
|
+
receiptPath ??= await saveSubmission({ prepared, origin, idempotencyKey, file: values.receipt, outputFile: values.output });
|
|
97
|
+
if (controller.signal.aborted) throw new CliError(130, 'Interrupted before submission. The receipt is available for recovery.');
|
|
98
|
+
log({ event: 'forecast_submission', idempotency_key: idempotencyKey, receipt_path: receiptPath, recovery: { command: 'ephemeris forecast run', args: ['--resume', receiptPath, '--output', 'recovered.json'] }, hint: 'Receipt contains full input history. Resume reuses the saved origin, request, and key; server retention rules apply.' });
|
|
99
|
+
}
|
|
100
|
+
const transport = {};
|
|
101
|
+
const response = await request({ origin, path: name === 'auth status' ? '/api/v1/balance' : path, method: command.method, key, body, idempotencyKey, timeout, signal: controller.signal, receipt: transport });
|
|
102
|
+
if (name === 'auth status') data = { configured: true, verified: true, source: 'EPHEMERIS_API_KEY', base_url: origin };
|
|
103
|
+
else if (name === 'forecast run') {
|
|
104
|
+
const safe = sanitize(response, key);
|
|
105
|
+
data = createArtifact(prepared, safe, { origin, idempotency_key: idempotencyKey, ...sanitize(transport, key), credentials_redacted: JSON.stringify(safe) !== JSON.stringify(response) });
|
|
106
|
+
paidArtifact = data;
|
|
107
|
+
} else data = sanitize(response, key);
|
|
108
|
+
}
|
|
109
|
+
await output(textResult ?? json(sanitize(data, key)), target);
|
|
110
|
+
return 0;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (paidArtifact && target) {
|
|
113
|
+
try { await output(json(sanitize(paidArtifact, key))); log({ event: 'artifact_recovered_on_stdout', hint: 'Save stdout as JSON. Description and plotting can be retried offline.' }); }
|
|
114
|
+
catch { log({ event: 'artifact_output_failed', hint: 'Retry with the same idempotency key and identical input; server retention rules apply.' }); }
|
|
115
|
+
}
|
|
116
|
+
const code = error instanceof CliError ? error.code : 7;
|
|
117
|
+
log({ error: { code, message: error instanceof CliError ? error.message : 'Unexpected local failure.', ...(error.details ?? {}), ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}), ...(receiptPath ? { receipt_path: receiptPath, next_action: 'Run ephemeris forecast run --resume <receipt_path> --output <new-file>. The server may have completed the original request; retention rules apply.' } : {}) } });
|
|
118
|
+
return code;
|
|
119
|
+
} finally {
|
|
120
|
+
await cleanup(target);
|
|
121
|
+
process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', interrupt);
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { CliError } from './errors.js';
|
|
2
|
+
import { DEFAULT_URL, VERSION } from './commands.js';
|
|
3
|
+
|
|
4
|
+
export function baseUrl(value = DEFAULT_URL) {
|
|
5
|
+
let url;
|
|
6
|
+
try { url = new URL(value); } catch { throw new CliError(2, 'Invalid base URL; use an HTTPS service origin.'); }
|
|
7
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
8
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
|
9
|
+
throw new CliError(2, 'Base URL must be an HTTPS origin without credentials, path, query, or fragment. HTTP is allowed only for loopback development.');
|
|
10
|
+
}
|
|
11
|
+
return url.origin;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function request({ origin, path, method = 'GET', key, body, idempotencyKey, timeout, signal, receipt }) {
|
|
15
|
+
const headers = { accept: 'application/json', authorization: `Bearer ${key}`, 'user-agent': `ephemeris-cli/${VERSION}` };
|
|
16
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
17
|
+
if (idempotencyKey) headers['idempotency-key'] = idempotencyKey;
|
|
18
|
+
let response, text;
|
|
19
|
+
try {
|
|
20
|
+
response = await fetch(`${origin}${path}`, {
|
|
21
|
+
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
|
|
22
|
+
// Do not forward credentials to a redirect destination or replay a billed POST.
|
|
23
|
+
redirect: 'manual',
|
|
24
|
+
signal: AbortSignal.any([AbortSignal.timeout(timeout * 1000), signal]),
|
|
25
|
+
});
|
|
26
|
+
text = await response.text();
|
|
27
|
+
} catch {
|
|
28
|
+
if (signal.aborted) throw new CliError(130, 'Interrupted. Forecast completion may be unknown; retain the idempotency key.');
|
|
29
|
+
throw new CliError(6, 'Network request failed or timed out. Forecast completion may be unknown; retry with the same idempotency key and identical input.');
|
|
30
|
+
}
|
|
31
|
+
const details = {
|
|
32
|
+
http_status: response.status,
|
|
33
|
+
request_id: response.headers.get('x-request-id'),
|
|
34
|
+
retry_after: response.headers.get('retry-after'),
|
|
35
|
+
};
|
|
36
|
+
if (receipt) Object.assign(receipt, details, { idempotency_replayed: response.headers.get('idempotency-replayed') });
|
|
37
|
+
let data;
|
|
38
|
+
try { data = JSON.parse(text); } catch {
|
|
39
|
+
// Do not echo arbitrary HTML, proxy errors, or credentials.
|
|
40
|
+
data = null;
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const status = response.status;
|
|
44
|
+
const code = [401, 403].includes(status) ? 3 : status === 402 ? 4 : [409, 429].includes(status) ? 5 : status >= 500 || status === 408 ? 6 : status === 400 || status === 422 ? 2 : 7;
|
|
45
|
+
const hints = { 2: 'Check the request with forecast --help.', 3: 'Check EPHEMERIS_API_KEY and its permissions.', 4: 'Add credits in the dashboard.', 5: 'Inspect Retry-After and the API error; preserve the key for identical forecast retries.', 6: 'Service unavailable; preserve the key for identical forecast retries.', 7: 'Check the service origin and API compatibility.' };
|
|
46
|
+
throw new CliError(code, `API returned HTTP ${status}. ${hints[code]}`, { ...details, api_error: data });
|
|
47
|
+
}
|
|
48
|
+
if (data === null || typeof data !== 'object') throw new CliError(7, 'API returned an invalid JSON result. Preserve the idempotency key before retrying a forecast.', details);
|
|
49
|
+
return data;
|
|
50
|
+
}
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const savedExample = "# Synthetic saved result for this offline example; not live predictions.\ncat > example-forecast.json <<'JSON'\n{\"kind\":\"ephemeris.forecast\",\"schema_version\":\"1\",\"artifact_id\":\"00000000-0000-4000-8000-000000000001\",\"created_at\":\"2026-01-04T00:00:00Z\",\"request\":{\"mode\":\"route\",\"series\":[{\"values\":[10,12,11],\"freq\":\"D\"}],\"horizon\":2,\"quantiles\":[0.1,0.5,0.9]},\"request_sha256\":\"8737423c8f6d653c73037497414069f2c4bf07bcf2d6ffcb7c98dfe6190ca1df\",\"response\":{\"forecasts\":[{\"quantiles\":{\"0.1\":[9,10],\"0.5\":[12,13],\"0.9\":[15,16]}}]},\"context\":{\"series\":[{\"unit\":\"units\",\"timezone\":\"UTC\",\"target_description\":\"Synthetic observed sales\",\"measurement\":\"period_total\",\"series_id\":null,\"timestamps\":[\"2026-01-01\",\"2026-01-02\",\"2026-01-03\"],\"future_axis\":{\"kind\":\"timestamps\",\"values\":[\"2026-01-04\",\"2026-01-05\"],\"reason\":\"Projected from the recorded regular grid; future observations have not occurred.\"},\"frequency_evidence\":{\"supplied_frequency\":\"D\",\"candidate_frequency\":\"D\",\"effective_frequency\":\"D\",\"basis\":\"caller_declared_grid\",\"invalid_timestamps\":0,\"duplicate_timestamps\":0,\"out_of_order_pairs\":0,\"missing_periods\":0,\"off_grid_intervals\":0,\"coverage\":{\"start\":\"2026-01-01\",\"end\":\"2026-01-03\"},\"regular\":true,\"reason\":null},\"source_available_at\":null,\"recorded_as_of\":null,\"future_covariate_roles\":{}}],\"scenario_assumptions\":[],\"snapshot_id\":null,\"source\":null},\"transport\":{},\"disclosure\":{\"input_history_included\":true,\"context_sent_to_api\":false,\"credentials_included\":false,\"provider_input_note\":\"All submitted history is saved. The service may truncate it using context_len and model context limits.\"},\"interoperability\":{\"gnomon_import\":\"not_implemented\",\"recorded_in_ledger\":false,\"identity_basis\":\"local artifact UUID and request SHA-256; hashes are not authenticity signatures\"}}\nJSON";
|
|
2
|
+
import { VERSION } from './version.js';
|
|
3
|
+
export { VERSION };
|
|
4
|
+
export const DEFAULT_URL = 'https://ephemeris.cascade.industries';
|
|
5
|
+
const string = (text, short) => ({ type: 'string', text, ...(short ? { short } : {}) });
|
|
6
|
+
const boolean = (text) => ({ type: 'boolean', text });
|
|
7
|
+
export const common = {
|
|
8
|
+
json: boolean('JSON output (default); forecast plot emits PNG/SVG instead. Errors: JSON Lines on stderr.'),
|
|
9
|
+
output: string('Save to a NEW file; never overwrites. Default stdout; - also means stdout. Artifacts/snapshots include input history.', 'o'),
|
|
10
|
+
help: { ...boolean('Complete offline help for this command.'), short: 'h' },
|
|
11
|
+
};
|
|
12
|
+
const network = {
|
|
13
|
+
'base-url': string('HTTPS service origin; default EPHEMERIS_BASE_URL or https://ephemeris.cascade.industries. HTTP allowed on loopback only.'),
|
|
14
|
+
timeout: string('HTTP deadline in seconds, 1–3600; default 300. No automatic retries.'),
|
|
15
|
+
};
|
|
16
|
+
const input = { input: string('Required file, or - for stdin. UTF-8; maximum 8 MiB.', 'i') };
|
|
17
|
+
const selection = {
|
|
18
|
+
'series-index': string('Zero-based artifact series index. Required for multiple series; otherwise 0.'),
|
|
19
|
+
'variate-index': string('Zero-based variate index. Required for multiple variates; otherwise 0. Series metadata applies to the selected variate.'),
|
|
20
|
+
};
|
|
21
|
+
const offline = 'Runs locally. No credentials, provider calls, credits, or network access. Does not change the input. Output is JSON unless this command specifies an image; --output writes a new file only.';
|
|
22
|
+
export const commands = {
|
|
23
|
+
version: {
|
|
24
|
+
summary: 'Inspect the installed CLI version, build identity and supported local record schemas.',
|
|
25
|
+
detail: `Runs offline without credentials, inference, charges or service checks. No required inputs. JSON is the default; --json is accepted explicitly. --output writes a new file and never overwrites; stdout is the default. ephemeris --version remains the short package version only.
|
|
26
|
+
|
|
27
|
+
Fields: component identifies this installed CLI; version comes from its package metadata; build_commit is the full source Git SHA stamped when a clean checkout was packed, or null when unavailable. artifact_schema_versions lists supported versions by record kind. A null commit is unknown, not evidence of an outdated install. Package, hosted MCP, bridge and artifact schema versions are independent. This command does not determine the deployed service version, latest npm version, model availability, account balance or forecast quality. Use MCP get_version for the hosted version and models list for live capabilities.
|
|
28
|
+
|
|
29
|
+
Examples below need no files or API key. Exit 0 means metadata was read; invalid options exit 2; output errors exit 8. A failure causes no network operation or paid forecast; correct the option or choose a new writable output path and rerun.`,
|
|
30
|
+
options: {},
|
|
31
|
+
example: 'ephemeris version\nephemeris version --json\nephemeris version --output cli-version.json\nephemeris --version',
|
|
32
|
+
},
|
|
33
|
+
'data inspect': {
|
|
34
|
+
summary: 'Inspect selected observed columns and freeze a reusable local data snapshot.',
|
|
35
|
+
detail: `Supported: CSV/TSV with headers, JSON arrays of row objects, JSONL row objects. Select --value-column explicitly; add --time-column for time coverage and grid checks. Optional --series-column groups independent series without guessing a target. Decimal text is parsed in CSV/TSV; JSON values must be numbers. No Excel, Parquet, gzip, implicit grouping, repairs, sorting, deduplication, resampling, or interpolation.
|
|
36
|
+
|
|
37
|
+
Output ephemeris.data v1 includes selected numeric history, original timestamp strings, source SHA-256, metadata, per-series readiness and reports. Missing/non-numeric cells are represented as null with separate counts and one-based source data-row numbers. Reports include observed range, frequency evidence, missing periods, duplicates, missing values, invalid cells, and zeros. Exit 0 means inspection completed, NOT ready to forecast. Check series[].report.ready_for_forecast. Counts refer to the selected observed window; no data outside it is inferred.
|
|
38
|
+
|
|
39
|
+
Supported time grids: whole-second subdaily (H, 15min, 30s), D, W, and month-start MS. ISO dates or ISO datetimes only; offsets must be explicit for aware timestamps. Supply an IANA --timezone when known. Named-zone naive subdaily timestamps are ambiguous and not ready. Frequency inferred from a regular window is only a candidate; --frequency declares the intended grid. Without timestamps, missing periods/coverage remain unknown and forecasting requires declared frequency. Unsupported calendars remain unverified. A horizon counts grid steps, not calendar days.
|
|
40
|
+
|
|
41
|
+
Preserve units and target meaning explicitly: period_total, period_average, or point_in_time. Observed sales are not necessarily underlying demand; zeros do not prove stockouts or lost demand. The snapshot freezes selected history; source edits do not change it. Fix bad source data explicitly and inspect again. ${offline}`,
|
|
42
|
+
options: { ...input, format: string('csv|tsv|json|jsonl. Inferred from filename extension; required for stdin.'), 'value-column': string('Required observed numeric target column; literal name.'), 'time-column': string('Optional ISO timestamp column; omitted means unknown coverage.'), 'series-column': string('Optional grouping column. Missing IDs reject; source order is preserved within each group.'), frequency: string('Declared intended sampling grid, e.g. H, 15min, D, W, MS. Default: evidence-based candidate, if unambiguous.'), unit: string('Caller-declared unit, e.g. AUD, units, kWh; default unknown. No conversion.'), timezone: string('Caller-declared IANA zone, e.g. Australia/Brisbane; default unknown. Does not silently localize ambiguous instants.'), 'target-description': string('Caller-supplied target meaning, e.g. observed daily sales; default unknown.'), measurement: string('period_total|period_average|point_in_time; default unknown.') },
|
|
43
|
+
example: `printf 'date,sales\\n2026-01-01,10\\n2026-01-02,12\\n' > example-sales.csv
|
|
44
|
+
ephemeris data inspect --input example-sales.csv --time-column date --value-column sales --frequency D --unit units --measurement period_total --target-description "Synthetic observed sales" --output inspected.json
|
|
45
|
+
ephemeris forecast run --input inspected.json --horizon 7 --quantiles 0.1,0.5,0.9 --dry-run
|
|
46
|
+
# For real work, use actual supplied observations, units, and target meaning.`,
|
|
47
|
+
},
|
|
48
|
+
'forecast run': {
|
|
49
|
+
summary: 'Submit a forecast and save a versioned artifact; this operation spends credits.',
|
|
50
|
+
detail: `Input: an ephemeris.data v1 snapshot, an ephemeris.request v1 prepared request, or a public API request such as:
|
|
51
|
+
{"mode":"route","series":[{"values":[10,12,11],"freq":"D"}],"horizon":2,"quantiles":[0.1,0.5,0.9]}
|
|
52
|
+
This differs from raw Paracast. For raw CSV/TSV/JSON rows, first use data inspect. For multi-series snapshots choose --series ID. Snapshot data must be complete and regular; no silent repair.
|
|
53
|
+
|
|
54
|
+
Modes: route selects automatically; ensemble blends compatible models; explicit requires model from models list. Raw/prepared JSON requires mode; snapshots default route. Explicit flags override input fields; model does not implicitly change mode. API defaults horizon=64 and context_len=256; use explicit values when required. Limits: 64 series, 256 variates, horizon 1–4096, context_len 1–16384. Each history is oldest first. Frequency belongs to each series; horizon counts sampling steps. Quantiles [0.1,0.5,0.9] give marginal median and a central nominal 80% interval, NOT proven calibrated coverage. Total variates × horizon × quantile count must not exceed 120000; omitted quantiles count as 21 for this limit. Model-specific limits may be smaller.
|
|
55
|
+
|
|
56
|
+
Optional local --context JSON is separate from the API request:
|
|
57
|
+
{"series":[{"unit":"units","measurement":"period_total","timezone":"UTC","target_description":"Synthetic observed sales","timestamps":["2026-01-01","2026-01-02","2026-01-03"]}]}
|
|
58
|
+
context.series aligns with request.series. Allowed per-series fields: series_id, unit, measurement, timezone, target_description, timestamps, future_timestamps, future_covariate_roles, source_available_at, recorded_as_of. Missing context stays null. Timestamps must match history length and a supported regular grid. Explicit future_timestamps must continue it for horizon steps. Named-zone calendar projection may fall back to steps unless explicit future timestamps are provided. Metadata applies to all variates in its series. Future roles map channel names to known_future|scenario_assumption|unspecified. Context and assertions are retained locally, never sent as model inputs. Narrative scenario assumptions do not affect inference. No future inputs or an empty assumptions list means not supplied; it does NOT mean the model assumes zero events, no promotions, no constraints, or unchanged conditions.
|
|
59
|
+
|
|
60
|
+
API series[].covariates.past numeric channels align with full history; future channels match horizon and need matching past names. Multivariate values are equal-length variate arrays. Only submitted numeric inputs affect the forecast; known future inputs and scenario assumptions must not be conflated. Use schema forecast for the wire contract. Raw requests reject unknown fields; put local context in --context.
|
|
61
|
+
|
|
62
|
+
--dry-run is fully offline, needs no key, costs nothing, and outputs a reusable ephemeris.request envelope (request plus local context). Submission returns an ephemeris.forecast v1 artifact, preserving request, full submitted history, response, context, timestamps, IDs, retry key, and billing. Billing is at response.meta.billing: settled_mc is charged millicredits; balance_mc is the remaining balance at that response. All _mc fields are exact integer strings: 1000 millicredits = 1 credit, so settled_mc="100" means 100 millicredits (0.1 credits), NOT 100 credits. Preserve these units and strings; missing billing is unknown. IDs are at response.meta.gateway_request_id, response.meta.request_id, and transport.request_id when supplied. transport.credentials_redacted=false means no credential-like response content needed redaction, not that credentials were included. Credentials are excluded. No describe or plot side effects occur. API results may include null quantiles; they remain missing. Service/model context caps can shorten the history actually used.
|
|
63
|
+
|
|
64
|
+
Idempotency-Key is printed BEFORE submission. A private, versioned ephemeris.submission v1 receipt is also synced to disk BEFORE the request. With --output forecast.json it defaults to forecast.json.submission.json; with stdout it uses .ephemeris/submissions/<uuid>.json in the current directory. --receipt selects another NEW path. Receipts contain the full prepared request/history/context, service origin, key, and request hash, never credentials. They remain after success or failure and do not prove completion. Existing receipts are never overwritten; a receipt write failure prevents submission. Archive or delete them explicitly when no longer needed.
|
|
65
|
+
|
|
66
|
+
Recover with forecast run --resume RECEIPT --output NEW_FILE. Resume reuses the exact stored request, key, context, and origin, ignoring EPHEMERIS_BASE_URL. It rejects input/model/horizon/quantile/context/key/base-url/receipt overrides; --timeout, --output, and --dry-run are allowed. --resume --dry-run validates offline and prints the prepared envelope. Only resume receipts you trust; their hashes are integrity checks, not signatures. Server retention rules apply: an expired key may result in a new charge; do not assume replay is retained indefinitely. Reuse it with the identical effective request after a timeout/interruption; a fresh key represents a new paid forecast. There are no automatic retries. Reserve the output path before submission. If writing a paid artifact fails, the CLI attempts recovery to stdout and exits 8. Save that JSON or retry with the same key (server retention rules apply). Later description/plot failures never rerun inference.`,
|
|
67
|
+
method: 'POST', path: '/api/v1/forecast',
|
|
68
|
+
options: { ...input, ...network, resume: string('Recover from a saved submission receipt. Reuses origin, request, and key; mutually exclusive with --input and request overrides.'), receipt: string('New private submission receipt path; default OUTPUT.submission.json or .ephemeris/submissions/<uuid>.json. No receipt is written by --dry-run.'), context: string('Optional local context JSON file or -. Not accepted with frozen snapshots/prepared requests. Only one input may be stdin.'), series: string('Exact series ID from a multi-series inspected snapshot.'), mode: string('route|ensemble|explicit; overrides input mode.'), model: string('Live model name for explicit mode; discover with models list.'), horizon: string('Future sampling steps, integer 1–4096; overrides input. API default 64.'), quantiles: string('Comma-separated unique levels in (0,1), maximum 21, e.g. 0.1,0.5,0.9. Overrides input; omit to retain API/input defaults.'), 'context-len': string('Most recent points per variate, 1–16384. Overrides input; API default 256.'), 'dry-run': boolean('Validate offline and print prepared request/context. No network or charge.'), 'idempotency-key': string('8–128 letters/digits/dot/underscore/colon/hyphen. Default new UUID; retain it for identical retries.') },
|
|
69
|
+
example: "# Complete synthetic input; replace with actual supplied observations for real work.\ncat > example-request.json <<'JSON'\n{\"mode\":\"route\",\"series\":[{\"values\":[10,12,11],\"freq\":\"D\"}],\"horizon\":2,\"quantiles\":[0.1,0.5,0.9]}\nJSON\ncat > example-context.json <<'JSON'\n{\"series\":[{\"unit\":\"units\",\"measurement\":\"period_total\",\"timezone\":\"UTC\",\"target_description\":\"Synthetic observed sales\",\"timestamps\":[\"2026-01-01\",\"2026-01-02\",\"2026-01-03\"]}]}\nJSON\n# Offline validation; no credentials or charge:\nephemeris forecast run --input example-request.json --context example-context.json --dry-run --output prepared.json\n# Paid submission: requires EPHEMERIS_API_KEY. Receipt is saved before network access.\nephemeris forecast run --input prepared.json --output forecast.json\n# Only after an uncertain result, recover to a NEW file (do not start a fresh request):\n# ephemeris forecast run --resume forecast.json.submission.json --output recovered.json\n# Other modes: add --mode ensemble, or --mode explicit --model NAME from models list.\n# Stdin: cat example-request.json | ephemeris forecast run --input - --dry-run",
|
|
70
|
+
},
|
|
71
|
+
'forecast describe': {
|
|
72
|
+
summary: 'Calculate deterministic interpretation of a saved forecast artifact, offline.',
|
|
73
|
+
detail: `Requires an ephemeris.forecast v1 artifact saved by forecast run. Select series/variate explicitly when there are multiple. Missing or corrected labels never require paid inference: copy the artifact to a NEW file, edit only its local context.series metadata using caller-supplied facts, leave request/response unchanged, then rerun describe/plot offline. Timestamps must pass the existing grid checks. No dedicated metadata-edit command exists. Returns a Gnomon-aligned overview: scope, result, basis, limitations, references, followups. JSON pointers refer to the INPUT artifact. This is not a Gnomon import or ledger record.
|
|
74
|
+
|
|
75
|
+
Shows observed count/mean/median/min/max/latest; final forecast median; change against the LAST OBSERVED value; arithmetic mean of per-step medians; and maximum marginal median with tied sampling steps. Percentage change uses 100*(final median-last)/abs(last); zero baseline gives null. A sum of medians is calculated only for caller-declared period_total and is NOT a median or interval of the cumulative total. Missing medians remain missing; no interpolation. Incomplete horizon-wide statistics and non-finite arithmetic return null. Missing units, timezone, and measurement remain unknown. result.billing labels saved millicredit amounts and exact decimal credit conversions; missing values remain null. This is not a current balance check.
|
|
76
|
+
|
|
77
|
+
Marginal quantiles do not establish calibrated coverage, path probabilities, cumulative intervals, actual peak distributions, causal explanations, trading actions, or inventory actions. Future-channel labels are caller assertions. Absent future inputs mean not supplied, not assumed zero or no events. A forecast jump or narrow/constant band does not by itself establish poor fit or quality; observed outcomes and evaluation are needed. This command makes no inference/LLM call, does not score outcomes, and does not schedule reviews. ${offline}`,
|
|
78
|
+
options: { ...input, ...selection }, example: `${savedExample}
|
|
79
|
+
ephemeris forecast describe --input example-forecast.json --output description.json
|
|
80
|
+
# For a real saved artifact: ephemeris forecast describe --input forecast.json
|
|
81
|
+
# Multiple results: add --series-index 0 --variate-index 0 (zero-based).`,
|
|
82
|
+
},
|
|
83
|
+
'forecast plot': {
|
|
84
|
+
summary: 'Render PNG or SVG from saved history, median, and quantile bands, offline.',
|
|
85
|
+
detail: `Requires an ephemeris.forecast v1 artifact with a returned 0.5 quantile. Select series/variate explicitly for multiple results. Choose --output forecast.png for a shareable image, or forecast.svg for scalable output. --format png|svg explicitly selects format; otherwise the filename extension selects it, with SVG as the default for stdout or extensionless files. Conflicting/unsupported extensions reject. PNG stdout is binary and must be piped; it is not written to an interactive terminal. PNG uses a packaged renderer and bundled font, offline. Missing/corrected labels do NOT require a new paid forecast: copy the artifact to a NEW file, edit only its local context.series metadata with caller-supplied facts, keep request/response unchanged, and rerun plot offline. Timestamps must pass the grid checks. No dedicated metadata-edit command exists. No plotting application, browser, or Python installation is required.
|
|
86
|
+
|
|
87
|
+
Grey is observed history. The default view shows the most recent max(24, 2*horizon) observations, capped by available history; --history N changes that count, --history all shows everything. The chart labels the shown/total counts; the full history is always retained in the artifact. View changes do not change inference or resample observations. Blue is the marginal median; the dashed line marks the forecast boundary. Default bands pair available complementary levels, e.g. q0.1–q0.9, and label nominal mass, not calibrated coverage. Use --lower and --upper together to select one existing band. Missing values break lines; missing/crossed interval points are omitted and counted. Median is never inferred from other quantiles. Available reliable timestamps are used; otherwise the axis uses sampling steps relative to the last observation (0). Named-zone calendar projection across DST can require explicit future_timestamps; no dates are fabricated.
|
|
88
|
+
|
|
89
|
+
Plots do not show cumulative uncertainty, path probabilities, peak distributions, or business recommendations. Unit/measurement/timezone are shown as supplied or unknown. Plot failure leaves the saved forecast intact; fix arguments or choose a new output path and rerun locally. ${offline}`,
|
|
90
|
+
options: { ...input, ...selection, format: string('png|svg. Default from --output extension, otherwise svg. PNG stdout is binary; filename must match format.'), history: string('Number of recent observations to display, 1–100000, or all. Default min(available, max(24, 2*horizon)); full history stays saved.'), lower: string('Lower quantile already in saved result, e.g. 0.1. Requires --upper.'), upper: string('Upper quantile already in saved result, e.g. 0.9. Requires --lower.') }, example: `${savedExample}
|
|
91
|
+
ephemeris forecast plot --input example-forecast.json --output forecast.png
|
|
92
|
+
ephemeris forecast plot --input example-forecast.json --output forecast.svg --history all
|
|
93
|
+
# For a real saved artifact: ephemeris forecast plot --input forecast.json --history 48 --lower 0.1 --upper 0.9 --output interval.png`,
|
|
94
|
+
},
|
|
95
|
+
'models list': {
|
|
96
|
+
summary: 'Discover live model capabilities, context/horizon limits, and prices.',
|
|
97
|
+
detail: 'Authenticated GET /api/v1/models. No forecast or forecast charge. Use before explicit model selection; names/capabilities are not hard-coded. Returns the unmodified catalog JSON, except credential-like fields are redacted. Catalog price_per_kslot_mc means millicredits per 1000 series-slots; billing_context_cap is the maximum billable context. 1000 millicredits = 1 credit. Treat absent capability/price fields as unknown. This does not establish model quality.',
|
|
98
|
+
method: 'GET', path: '/api/v1/models', options: network, example: 'ephemeris models list --json',
|
|
99
|
+
},
|
|
100
|
+
balance: {
|
|
101
|
+
summary: 'Read available credits and active holds without running a forecast.',
|
|
102
|
+
detail: 'Authenticated GET /api/v1/balance. No forecast or forecast charge. Fields ending in _mc are millicredit strings; 1000 millicredits = 1 credit ("100" mc is 0.1 credits, not 100 credits). Preserve them to avoid integer precision loss. No billing state is changed.',
|
|
103
|
+
method: 'GET', path: '/api/v1/balance', options: network, example: 'ephemeris balance --json',
|
|
104
|
+
},
|
|
105
|
+
'usage list': {
|
|
106
|
+
summary: 'Read one page of forecast request history and charges.',
|
|
107
|
+
detail: 'Authenticated GET /api/v1/usage. No forecast or forecast charge. Use pagination.next_offset for the next page. Rows are in data[]. requestId identifies the gateway request; paracastRequestId identifies the provider request when supplied. estimatedMc and settledMc are exact millicredit strings, not credits; status is the recorded HTTP status when supplied. Preserve IDs and strings; 1000 millicredits = 1 credit. This does not ingest actuals or score forecasts.',
|
|
108
|
+
method: 'GET', path: '/api/v1/usage', options: { ...network, limit: string('Rows per page, integer 1–200; default 50.'), offset: string('Rows to skip, integer >=0; default 0.') }, example: 'ephemeris usage list --limit 20 --offset 0 --json',
|
|
109
|
+
},
|
|
110
|
+
'auth status': {
|
|
111
|
+
summary: 'Check local credential configuration; optionally verify it with the API.',
|
|
112
|
+
detail: 'Set EPHEMERIS_API_KEY from your environment/secret manager. No credential arguments or stored credential files. Create a key at https://ephemeris.cascade.industries/dashboard/api-keys. Default reports configured, not verified. --verify performs GET /api/v1/balance; no forecast charge. Keys are never printed. On 401/403 replace/check the key and permissions.',
|
|
113
|
+
options: { ...network, verify: boolean('Verify key with balance endpoint; default local configuration check only.') }, example: 'ephemeris auth status --verify',
|
|
114
|
+
},
|
|
115
|
+
'schema forecast': {
|
|
116
|
+
summary: 'Print the bundled public forecast request JSON Schema, offline.',
|
|
117
|
+
detail: `Default --kind request prints the public API request contract. --kind artifact, prepared, data, or submission prints the corresponding local version 1 schema. Each output is standalone JSON Schema with local $defs references. API validation remains authoritative. Local semantic checks additionally enforce shape, covariate alignment, and explicit-mode model selection. Artifacts preserve request/response/context, IDs and billing; prepared records separate the wire request from local context; data records freeze selected observations and diagnostics; submission receipts preserve the exact request and retry key before network access. See forecast run --help for usage. ${offline}`,
|
|
118
|
+
options: { kind: string('request|artifact|prepared|data|submission. Default request (public wire format); other kinds are local version 1 contracts.') }, example: 'ephemeris schema forecast --output forecast-schema.json\nephemeris schema forecast --kind artifact\nephemeris schema forecast --kind data',
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
export const footer = `Authentication for remote operations: EPHEMERIS_API_KEY environment only.
|
|
122
|
+
Success: JSON (PNG/SVG for plot) on stdout or a new --output file. Errors: JSON Lines on stderr.
|
|
123
|
+
Exit codes: 0 operation success; 2 input/configuration; 3 authentication/permission;
|
|
124
|
+
4 insufficient credits; 5 conflict/rate limit; 6 network/timeout/service;
|
|
125
|
+
7 API/protocol error; 8 file I/O; 130 interrupted.
|
|
126
|
+
401/403: check credentials. 402: add credits. 409/429: inspect API error/Retry-After.
|
|
127
|
+
Timeout: completion may be unknown; use forecast run --resume RECEIPT to reuse the saved request/key. Server retention rules apply.
|
|
128
|
+
Offline commands need no credentials and do not contact the service.
|
|
129
|
+
Missing units, target/business definitions, or future assumptions: ask the user;
|
|
130
|
+
preserve unknowns rather than filling them with plausible guesses. No operation
|
|
131
|
+
establishes forecast accuracy, calibrated coverage, or permission for business actions.
|
|
132
|
+
Missing future inputs mean not supplied, not assumed zero/no events.
|
|
133
|
+
A forecast jump or narrow/constant band alone does not demonstrate good or poor quality.
|
|
134
|
+
Billing _mc values are millicredits: 1000 mc = 1 credit; "100" mc = 0.1 credits.
|
|
135
|
+
Missing labels alone never require new paid inference. Copy a saved artifact to a
|
|
136
|
+
NEW file, correct only caller-supplied local context metadata, keep request/response
|
|
137
|
+
unchanged, and rerun describe/plot offline. No dedicated metadata-edit command exists.`;
|
|
138
|
+
export function help(name) {
|
|
139
|
+
if (!name) return `Ephemeris ${VERSION} — inspect, forecast, describe, plot\n\nUsage: ephemeris <command> [options]\n\n${Object.entries(commands).map(([n, c]) => ` ${n.padEnd(20)} ${c.summary}`).join('\n')}\n\nDiscover each operation with <command> --help. Start with data inspect --help.
|
|
140
|
+
Workflow: inspect -> forecast run -> forecast describe -> forecast plot.
|
|
141
|
+
Each operation is explicit; only forecast run submits paid inference.
|
|
142
|
+
Options can precede or follow commands. --version prints the version.
|
|
143
|
+
Compatibility: forecast --input ... aliases forecast run; results are now artifacts.
|
|
144
|
+
\n${footer}\n`;
|
|
145
|
+
const c = commands[name];
|
|
146
|
+
return `Usage: ephemeris ${name} [options]\n\n${c.summary}\n\n${c.detail}\n\nOptions:\n${Object.entries({ ...c.options, ...common }).map(([n, o]) => ` --${n}${o.short ? `, -${o.short}` : ''}${o.type === 'string' ? ' <value>' : ''}\n ${o.text}`).join('\n')}\n\nExamples:\n${c.example}\n\n${footer}\n`;
|
|
147
|
+
}
|
package/src/data.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
import { timeEvidence, checkZone } from './time.js';
|
|
5
|
+
|
|
6
|
+
export const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
export const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
export const fail = (message) => { throw new CliError(2, message); };
|
|
9
|
+
|
|
10
|
+
function delimited(text, delimiter) {
|
|
11
|
+
const rows = []; let row = [], cell = '', quoted = false, closed = false;
|
|
12
|
+
for (let i = 0; i < text.length; i++) {
|
|
13
|
+
const c = text[i];
|
|
14
|
+
if (quoted) {
|
|
15
|
+
if (c === '"' && text[i + 1] === '"') { cell += '"'; i++; }
|
|
16
|
+
else if (c === '"') { quoted = false; closed = true; }
|
|
17
|
+
else cell += c;
|
|
18
|
+
} else if (c === '"') {
|
|
19
|
+
if (cell || closed) fail('Malformed quoted CSV/TSV field.');
|
|
20
|
+
quoted = true;
|
|
21
|
+
} else if (c === delimiter || c === '\n' || c === '\r') {
|
|
22
|
+
row.push(cell); cell = ''; closed = false;
|
|
23
|
+
if (c !== delimiter) { rows.push(row); row = []; if (c === '\r' && text[i + 1] === '\n') i++; }
|
|
24
|
+
} else {
|
|
25
|
+
if (closed) fail('Unexpected characters after a quoted field.');
|
|
26
|
+
cell += c;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (quoted) fail('Unclosed quoted CSV/TSV field.');
|
|
30
|
+
if (cell || row.length || closed) { row.push(cell); rows.push(row); }
|
|
31
|
+
if (rows.length < 2) fail('Expected a header and at least one data row.');
|
|
32
|
+
const names = rows.shift();
|
|
33
|
+
if (names.some((n) => !n) || new Set(names).size !== names.length) fail('Column names must be non-empty and unique.');
|
|
34
|
+
return rows.map((r) => {
|
|
35
|
+
if (r.length !== names.length) fail('Rows must have the same number of fields as the header; blank rows are not silently dropped.');
|
|
36
|
+
return Object.fromEntries(names.map((name, i) => [name, r[i]]));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function metadata(options = {}) {
|
|
41
|
+
const context = { unit: options.unit ?? null, timezone: options.timezone ?? null, target_description: options['target-description'] ?? null, measurement: options.measurement ?? null };
|
|
42
|
+
for (const [k, v] of Object.entries(context)) if (v !== null && (typeof v !== 'string' || !v.trim())) fail(`${k} must be a non-empty string when supplied.`);
|
|
43
|
+
if (context.measurement && !['period_total', 'period_average', 'point_in_time'].includes(context.measurement)) fail('measurement must be period_total, period_average, or point_in_time.');
|
|
44
|
+
checkZone(context.timezone);
|
|
45
|
+
return context;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inspect(text, options, file = '') {
|
|
49
|
+
const format = options.format || ({ '.csv': 'csv', '.tsv': 'tsv', '.json': 'json', '.jsonl': 'jsonl', '.ndjson': 'jsonl' })[extname(file).toLowerCase()];
|
|
50
|
+
if (!['csv', 'tsv', 'json', 'jsonl'].includes(format)) fail('Choose --format csv|tsv|json|jsonl (required for stdin or an unknown extension).');
|
|
51
|
+
if (!options['value-column']) fail('--value-column is required; choose the observed target explicitly.');
|
|
52
|
+
const content = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
53
|
+
let rows;
|
|
54
|
+
try { rows = format === 'csv' || format === 'tsv' ? delimited(content, format === 'csv' ? ',' : '\t') : format === 'json' ? JSON.parse(content) : content.trimEnd().split(/\r?\n/).map((line) => JSON.parse(line)); }
|
|
55
|
+
catch (error) { if (error instanceof CliError) throw error; fail('Malformed data file. JSON must be an array of row objects; JSONL needs one row object per line.'); }
|
|
56
|
+
if (!Array.isArray(rows) || !rows.length || rows.length > 100000 || rows.some((r) => !isObject(r))) fail('Data must contain 1–100000 row objects.');
|
|
57
|
+
const selected = ['value-column', 'time-column', 'series-column'].filter((k) => options[k]);
|
|
58
|
+
if (new Set(selected.map((k) => options[k])).size !== selected.length) fail('Select distinct columns for value, time, and series.');
|
|
59
|
+
for (const key of selected) if (!rows.some((r) => Object.hasOwn(r, options[key]))) fail(`Selected ${key} does not exist. Available columns: ${Object.keys(rows[0]).join(', ')}.`);
|
|
60
|
+
const context = metadata(options);
|
|
61
|
+
const groups = new Map();
|
|
62
|
+
let identifierType;
|
|
63
|
+
for (const [i, row] of rows.entries()) {
|
|
64
|
+
const id = options['series-column'] ? row[options['series-column']] : 'series-0';
|
|
65
|
+
if ((typeof id !== 'string' && typeof id !== 'number') || (typeof id === 'number' && !Number.isFinite(id)) || String(id).trim() === '') fail('Every row must have a non-empty selected series identifier.');
|
|
66
|
+
if (identifierType && identifierType !== typeof id) fail('Series identifiers must use a consistent type; numeric and string IDs are not silently merged.');
|
|
67
|
+
identifierType = typeof id;
|
|
68
|
+
if (!groups.has(String(id))) groups.set(String(id), []);
|
|
69
|
+
groups.get(String(id)).push({ row: i + 1, value: row[options['value-column']], timestamp: options['time-column'] ? row[options['time-column']] ?? null : null });
|
|
70
|
+
}
|
|
71
|
+
const series = [...groups].map(([series_id, entries]) => {
|
|
72
|
+
let missing = 0, invalid = 0;
|
|
73
|
+
const missingRows = [], invalidRows = [];
|
|
74
|
+
const values = entries.map((e) => {
|
|
75
|
+
const v = e.value;
|
|
76
|
+
if (v == null || (typeof v === 'string' && /^\s*(?:NA|N\/A|null|nan)?\s*$/i.test(v))) { missing++; missingRows.push(e.row); return null; }
|
|
77
|
+
const numeric = typeof v === 'number' ? v : (format === 'csv' || format === 'tsv') && typeof v === 'string' && /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(v.trim()) ? Number(v) : NaN;
|
|
78
|
+
if (!Number.isFinite(numeric)) { invalid++; invalidRows.push(e.row); return null; }
|
|
79
|
+
return numeric;
|
|
80
|
+
});
|
|
81
|
+
const timestamps = options['time-column'] ? entries.map((e) => e.timestamp) : null;
|
|
82
|
+
const time = timeEvidence(timestamps, options.frequency ?? null, context.timezone);
|
|
83
|
+
const ready = !missing && !invalid && values.length >= 2 && (timestamps ? time.regular : Boolean(options.frequency));
|
|
84
|
+
return { series_id, values, timestamps, context: { ...context, frequency: time.effective_frequency }, report: {
|
|
85
|
+
rows: entries.length, observed_values: values.filter((v) => v !== null).length, zeros: values.filter((v) => v === 0).length,
|
|
86
|
+
missing_values: missing, invalid_values: invalid, missing_value_rows: missingRows, invalid_value_rows: invalidRows, time,
|
|
87
|
+
ready_for_forecast: ready, readiness_scope: 'local data shape and grid only; provider suitability not established',
|
|
88
|
+
} };
|
|
89
|
+
});
|
|
90
|
+
return { kind: 'ephemeris.data', schema_version: '1', snapshot_id: hash(JSON.stringify(series)), source: { sha256: hash(text), format, columns: Object.fromEntries(selected.map((k) => [k, options[k]])), row_number_basis: 'one-based data row, excluding header' }, series, repairs: [], history_included: true,
|
|
91
|
+
limitations: ['Selected input history is included in this snapshot. No sorting, filling, deduplication, or resampling was performed.', 'Observed zeros are measurements, not proof of no underlying demand, a stockout, or a causal explanation.', 'Availability/revision cutoffs were not supplied; this is not a historical leakage check.'] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function validateSnapshot(value) {
|
|
95
|
+
if (value?.kind !== 'ephemeris.data' || value.schema_version !== '1' || !Array.isArray(value.series)) fail('Expected an ephemeris.data version 1 snapshot.');
|
|
96
|
+
if (value.snapshot_id !== hash(JSON.stringify(value.series))) fail('Snapshot contents changed. Reinspect the original source; the hash is an integrity check, not an authenticity signature.');
|
|
97
|
+
for (const s of value.series) {
|
|
98
|
+
if (!isObject(s) || !Array.isArray(s.values) || !isObject(s.context)) fail('Malformed data snapshot.');
|
|
99
|
+
const time = timeEvidence(s.timestamps, s.context.frequency, s.context.timezone);
|
|
100
|
+
if (s.values.length < 2 || s.values.some((v) => typeof v !== 'number' || !Number.isFinite(v)) || (s.timestamps !== null && (s.timestamps?.length !== s.values.length || !time.regular)) || (s.timestamps === null && !s.context.frequency)) fail('Snapshot contains missing/invalid values or an incomplete/unsupported time grid. Correct the source explicitly and inspect again.');
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|