space-data-module-sdk 0.8.13 → 0.8.15
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 +18 -1
- package/bin/space-data-module.js +91 -0
- package/docs/conformance.md +92 -0
- package/docs/flatsql-host-contract.md +34 -1
- package/docs/propagator-abi.md +28 -9
- package/package.json +6 -2
- package/src/conformance/abiDriver.js +225 -0
- package/src/conformance/index.js +151 -0
- package/src/conformance/propagatorSuite.js +526 -0
- package/src/conformance/selfTest.js +275 -0
- package/src/conformance/selfTestCorpus.js +68 -0
- package/src/conformance/twoBodyReference.js +132 -0
- package/src/testing/isomorphicHarness.js +233 -0
- package/templates/propagator-module/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `space-data-module conformance <family>` — the official-harness conformance
|
|
3
|
+
* runner (finding graph/findings/official-harness-shapes.md §5; W1.4 of the
|
|
4
|
+
* harness program).
|
|
5
|
+
*
|
|
6
|
+
* WASM artifacts ONLY (owner ruling 2026-08-10: "No JS propagator!!!! WASM
|
|
7
|
+
* ONLY") — the runner instantiates a compiled module and drives the family's
|
|
8
|
+
* ABI; there is no path that certifies a JS object, because JS registries are
|
|
9
|
+
* internal engine plumbing, never a public contract.
|
|
10
|
+
*
|
|
11
|
+
* Verdict vocabulary matches the gauntlet's: PASS / PASS-WITH-GAPS / FAIL.
|
|
12
|
+
* A gap is a check that could not be adjudicated HERE (no corpus, or a lane
|
|
13
|
+
* another command owns) — named, never silent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { loadPropagatorArtifact } from "./abiDriver.js";
|
|
21
|
+
import { computeVerdict, runPropagatorSuite } from "./propagatorSuite.js";
|
|
22
|
+
|
|
23
|
+
export { ErrorCode, REQUIRED_ABI_EXPORTS } from "./abiDriver.js";
|
|
24
|
+
export {
|
|
25
|
+
computeVerdict,
|
|
26
|
+
runPropagatorSuite,
|
|
27
|
+
DEFAULT_LEAK_OPTIONS,
|
|
28
|
+
} from "./propagatorSuite.js";
|
|
29
|
+
export { runPropagatorSelfTest, formatSelfTestReport } from "./selfTest.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Families with a conformance kit. The vocabulary is the SDS pluginCategory
|
|
33
|
+
* projection (W0.3): unknown families are refused BY NAME with the known set,
|
|
34
|
+
* never coerced — the ANALYSIS fallback was the namespace corruption the
|
|
35
|
+
* finding killed.
|
|
36
|
+
*/
|
|
37
|
+
export const CONFORMANCE_FAMILIES = Object.freeze(["propagator"]);
|
|
38
|
+
|
|
39
|
+
export class UnknownConformanceFamilyError extends Error {
|
|
40
|
+
constructor(family) {
|
|
41
|
+
super(
|
|
42
|
+
`no conformance kit for family "${family}" — kits exist for: ` +
|
|
43
|
+
`${CONFORMANCE_FAMILIES.join(", ")}. A family with no kit can never be CORE ` +
|
|
44
|
+
"(finding §5); maneuver is Wave 2 (EXPERIMENTAL), OD is deferred by ruling.",
|
|
45
|
+
);
|
|
46
|
+
this.name = "UnknownConformanceFamilyError";
|
|
47
|
+
this.family = family;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Locate a module's own corpus: <package-root>/vectors/vectors.json, walking
|
|
53
|
+
* up from the artifact (dist/isomorphic/module.wasm -> package root).
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveCorpusPath(artifactPath) {
|
|
56
|
+
let dir = path.dirname(path.resolve(artifactPath));
|
|
57
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
58
|
+
const candidate = path.join(dir, "vectors", "vectors.json");
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(candidate);
|
|
61
|
+
return candidate;
|
|
62
|
+
} catch {
|
|
63
|
+
// keep walking
|
|
64
|
+
}
|
|
65
|
+
const parent = path.dirname(dir);
|
|
66
|
+
if (parent === dir) break;
|
|
67
|
+
dir = parent;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function loadCorpus(vectorsPath) {
|
|
73
|
+
const raw = await fs.readFile(vectorsPath, "utf8");
|
|
74
|
+
const corpus = JSON.parse(raw);
|
|
75
|
+
if (!Array.isArray(corpus.cases)) {
|
|
76
|
+
throw new Error(`${vectorsPath} has no cases[] — not a conformance corpus`);
|
|
77
|
+
}
|
|
78
|
+
return corpus;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run conformance for one family against one WASM artifact.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} options
|
|
85
|
+
* @param {string} options.family e.g. "propagator"
|
|
86
|
+
* @param {string} options.artifactPath dist/isomorphic/module.wasm
|
|
87
|
+
* @param {string} [options.vectorsPath] corpus override; default = the
|
|
88
|
+
* module's own vectors/vectors.json, found by walking up from the artifact
|
|
89
|
+
* @param {object} [options.leak] {warmupCycles, measureCycles, entities}
|
|
90
|
+
*/
|
|
91
|
+
export async function runConformance(options) {
|
|
92
|
+
const family = String(options.family ?? "").trim().toLowerCase();
|
|
93
|
+
if (!CONFORMANCE_FAMILIES.includes(family)) {
|
|
94
|
+
throw new UnknownConformanceFamilyError(options.family);
|
|
95
|
+
}
|
|
96
|
+
const artifactPath = path.resolve(options.artifactPath);
|
|
97
|
+
const artifactBytes = await fs.readFile(artifactPath);
|
|
98
|
+
const artifactSha256 = crypto
|
|
99
|
+
.createHash("sha256")
|
|
100
|
+
.update(artifactBytes)
|
|
101
|
+
.digest("hex");
|
|
102
|
+
|
|
103
|
+
let vectorsPath = options.vectorsPath
|
|
104
|
+
? path.resolve(options.vectorsPath)
|
|
105
|
+
: await resolveCorpusPath(artifactPath);
|
|
106
|
+
let corpus = null;
|
|
107
|
+
if (vectorsPath) {
|
|
108
|
+
corpus = await loadCorpus(vectorsPath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const checks = await runPropagatorSuite(
|
|
112
|
+
() => loadPropagatorArtifact(artifactPath),
|
|
113
|
+
{ corpus, leak: options.leak },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
family,
|
|
118
|
+
artifact: { path: artifactPath, sha256: artifactSha256 },
|
|
119
|
+
corpus: vectorsPath
|
|
120
|
+
? {
|
|
121
|
+
path: vectorsPath,
|
|
122
|
+
schemaVersion: corpus.schemaVersion ?? null,
|
|
123
|
+
cases: corpus.cases.length,
|
|
124
|
+
model: corpus.conformance?.model ?? null,
|
|
125
|
+
}
|
|
126
|
+
: null,
|
|
127
|
+
checks,
|
|
128
|
+
verdict: computeVerdict(checks),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function formatConformanceReport(report) {
|
|
133
|
+
const lines = [];
|
|
134
|
+
lines.push(`conformance ${report.family} — ${report.verdict}`);
|
|
135
|
+
lines.push(` artifact ${report.artifact.path}`);
|
|
136
|
+
lines.push(` sha256 ${report.artifact.sha256}`);
|
|
137
|
+
if (report.corpus) {
|
|
138
|
+
lines.push(
|
|
139
|
+
` corpus ${report.corpus.path} (${report.corpus.cases} cases` +
|
|
140
|
+
`${report.corpus.model ? `, model: ${report.corpus.model}` : ""})`,
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
lines.push(" corpus none supplied");
|
|
144
|
+
}
|
|
145
|
+
for (const check of report.checks) {
|
|
146
|
+
const marker =
|
|
147
|
+
check.status === "pass" ? "PASS" : check.status === "gap" ? "GAP " : "FAIL";
|
|
148
|
+
lines.push(` [${marker}] ${check.id} — ${check.detail}`);
|
|
149
|
+
}
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The propagator-family conformance suite — W1.4 of
|
|
3
|
+
* graph/tasks/official-harness-shapes-program.md, the runner the finding's §5
|
|
4
|
+
* promises: one command, family-dispatched, shipped with its own negative
|
|
5
|
+
* control.
|
|
6
|
+
*
|
|
7
|
+
* Tier structure (finding §5):
|
|
8
|
+
* Tier 0 — structural: real instantiation + required export set. Full
|
|
9
|
+
* cross-runtime parity stays the parity-gate's job (the existing
|
|
10
|
+
* gate, unchanged); this runner reports that lane as a GAP rather
|
|
11
|
+
* than quietly re-certifying half of it.
|
|
12
|
+
* Tier B — anchors from the module's vectors corpus (vectors.json +
|
|
13
|
+
* PROVENANCE.md format). The corpus is the MODULE's: anchors are
|
|
14
|
+
* model-specific, so a two-body corpus is never forced onto an
|
|
15
|
+
* SGP4 module. No corpus => Tier B is a named gap, not a pass.
|
|
16
|
+
* Tier C — invariants with no stored expectation. PHYSICS invariants
|
|
17
|
+
* (vis-viva, period closure) run only where the corpus declares
|
|
18
|
+
* them applicable to the module's model; ABI-BEHAVIOUR invariants
|
|
19
|
+
* (determinism, frame/flags/reserved declaration, batch/single
|
|
20
|
+
* agreement, typed refusals, create-returns-handle) are
|
|
21
|
+
* model-independent and always run.
|
|
22
|
+
* Tier 4 — lifecycle: the leak test (docs/propagator-abi.md §Lifetime),
|
|
23
|
+
* destroy idempotence, and the negative control proving the leak
|
|
24
|
+
* metric can move at all. A gate never observed to fail is
|
|
25
|
+
* indistinguishable from one that cannot fail.
|
|
26
|
+
*
|
|
27
|
+
* Every check runs against the driver INTERFACE (abiDriver.js), so the
|
|
28
|
+
* self-test can prove the suite catches planted defects without a toolchain.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
ErrorCode,
|
|
33
|
+
REQUIRED_ABI_EXPORTS,
|
|
34
|
+
ReferenceFrame,
|
|
35
|
+
StateFlags,
|
|
36
|
+
} from "./abiDriver.js";
|
|
37
|
+
import {
|
|
38
|
+
EARTH_ROTATION_RATE,
|
|
39
|
+
SECONDS_PER_DAY,
|
|
40
|
+
withinBand,
|
|
41
|
+
} from "./twoBodyReference.js";
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_LEAK_OPTIONS = Object.freeze({
|
|
44
|
+
warmupCycles: 20,
|
|
45
|
+
measureCycles: 200,
|
|
46
|
+
entities: 256,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** PASS / PASS-WITH-GAPS / FAIL — the gauntlet's verdict vocabulary. */
|
|
50
|
+
export function computeVerdict(checks) {
|
|
51
|
+
if (checks.some((check) => check.status === "fail")) return "FAIL";
|
|
52
|
+
if (checks.some((check) => check.status === "gap")) return "PASS-WITH-GAPS";
|
|
53
|
+
return "PASS";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Inputs used for model-independent checks when no corpus is supplied.
|
|
58
|
+
*
|
|
59
|
+
* Near-circular, with mean anomalies at 0/180: the period-closure invariant
|
|
60
|
+
* compares radii one period apart, and the Julian-date grid carries ~4e-5 s
|
|
61
|
+
* of rounding at these magnitudes, so elements are chosen (as the reference
|
|
62
|
+
* corpus chose Molniya-at-perigee) where the radius is insensitive to that
|
|
63
|
+
* epsilon rather than papering over it with a looser band.
|
|
64
|
+
*/
|
|
65
|
+
export const GENERIC_ELEMENTS = Object.freeze(
|
|
66
|
+
Array.from({ length: 5 }, (_, index) =>
|
|
67
|
+
Object.freeze({
|
|
68
|
+
epochJd: 2460000.5,
|
|
69
|
+
meanMotionRevPerDay: 15.5 - index * 0.7,
|
|
70
|
+
eccentricity: 0.0006703 + index * 0.0005,
|
|
71
|
+
inclinationDeg: 51.64 + index * 5,
|
|
72
|
+
raOfAscNodeDeg: (208.9163 + index * 17) % 360,
|
|
73
|
+
argOfPericenterDeg: (30.8756 + index * 23) % 360,
|
|
74
|
+
meanAnomalyDeg: (index * 180) % 360,
|
|
75
|
+
noradCatId: 25544 + index,
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
function corpusInvariantIds(corpus) {
|
|
81
|
+
return new Set((corpus?.invariants ?? []).map((entry) => entry.id));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function corpusElements(corpus) {
|
|
85
|
+
const cases = corpus?.cases ?? [];
|
|
86
|
+
const elements = cases.map((entry) => entry.params.elements);
|
|
87
|
+
return elements.length > 0 ? elements : [...GENERIC_ELEMENTS];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pass(id, tier, required, detail) {
|
|
91
|
+
return { id, tier, required, status: "pass", detail };
|
|
92
|
+
}
|
|
93
|
+
function fail(id, tier, required, detail) {
|
|
94
|
+
return { id, tier, required, status: "fail", detail };
|
|
95
|
+
}
|
|
96
|
+
function gap(id, tier, detail) {
|
|
97
|
+
return { id, tier, required: false, status: "gap", detail };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run the suite. `driverFactory` returns a FRESH driver per call — checks that
|
|
102
|
+
* depend on pre-ingest state (typed refusals) or memory baselines (leak)
|
|
103
|
+
* must not inherit another check's arena.
|
|
104
|
+
*/
|
|
105
|
+
export async function runPropagatorSuite(driverFactory, options = {}) {
|
|
106
|
+
const corpus = options.corpus ?? null;
|
|
107
|
+
const leak = { ...DEFAULT_LEAK_OPTIONS, ...(options.leak ?? {}) };
|
|
108
|
+
const invariants = corpusInvariantIds(corpus);
|
|
109
|
+
const elements = corpusElements(corpus);
|
|
110
|
+
const checks = [];
|
|
111
|
+
|
|
112
|
+
async function run(id, tier, required, body) {
|
|
113
|
+
let driver;
|
|
114
|
+
try {
|
|
115
|
+
driver = await driverFactory();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
checks.push(
|
|
118
|
+
fail(id, tier, required, `driver failed to instantiate: ${error.message}`),
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const detail = await body(driver);
|
|
124
|
+
checks.push(pass(id, tier, required, detail ?? "ok"));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
checks.push(fail(id, tier, required, error.message));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- Tier 0 — structural ------------------------------------------------
|
|
131
|
+
await run("tier0/instantiation-and-exports", "0", true, async (driver) => {
|
|
132
|
+
const names = new Set(driver.exportNames());
|
|
133
|
+
const missing = REQUIRED_ABI_EXPORTS.filter((name) => !names.has(name));
|
|
134
|
+
if (missing.length > 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`artifact instantiates but is missing required ABI exports: ${missing.join(", ")}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return `all ${REQUIRED_ABI_EXPORTS.length} required exports present`;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
checks.push(
|
|
143
|
+
gap(
|
|
144
|
+
"tier0/parity-gate",
|
|
145
|
+
"0",
|
|
146
|
+
"cross-runtime byte-identity is the parity gate's verdict, not this runner's — " +
|
|
147
|
+
"certify with `space-data-module parity-gate --artifact <id>=<module.wasm>:module`",
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// ---- Tier B — corpus anchors -------------------------------------------
|
|
152
|
+
if (!corpus || !(corpus.cases?.length > 0)) {
|
|
153
|
+
checks.push(
|
|
154
|
+
gap(
|
|
155
|
+
"tierB/anchors",
|
|
156
|
+
"B",
|
|
157
|
+
"no vectors corpus supplied (--vectors or <package>/vectors/vectors.json) — " +
|
|
158
|
+
"Tier B anchors were not adjudicated; a module without a corpus cannot claim them",
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
await run("tierB/anchors", "B", true, async (driver) => {
|
|
163
|
+
let checked = 0;
|
|
164
|
+
for (const testCase of corpus.cases) {
|
|
165
|
+
const { elements: caseElements, julianDate } = testCase.params;
|
|
166
|
+
const ingested = driver.initFromOmm([caseElements]);
|
|
167
|
+
if (ingested !== 1) {
|
|
168
|
+
throw new Error(`${testCase.id}: ingest returned ${ingested}`);
|
|
169
|
+
}
|
|
170
|
+
const { status, state } = driver.propagate(julianDate, 0);
|
|
171
|
+
if (status !== ErrorCode.OK) {
|
|
172
|
+
throw new Error(`${testCase.id}: propagate status ${status}`);
|
|
173
|
+
}
|
|
174
|
+
for (const axis of [0, 1, 2]) {
|
|
175
|
+
const p = state.position[axis];
|
|
176
|
+
const v = state.velocity[axis];
|
|
177
|
+
if (!Number.isFinite(p) || !Number.isFinite(v)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${testCase.id}: non-finite component (NaN is its own failure class, ` +
|
|
180
|
+
`never "a number that happened")`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const expectedP = testCase.expect[`position.${axis}`];
|
|
184
|
+
if (!withinBand(p, expectedP, testCase.band.position)) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${testCase.id}: position[${axis}] = ${p} but the corpus anchor says ` +
|
|
187
|
+
`${expectedP} (delta ${p - expectedP} m)`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const expectedV = testCase.expect[`velocity.${axis}`];
|
|
191
|
+
if (!withinBand(v, expectedV, testCase.band.velocity)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`${testCase.id}: velocity[${axis}] = ${v} but the corpus anchor says ` +
|
|
194
|
+
`${expectedV} (delta ${v - expectedV} m/s)`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!withinBand(state.epoch, testCase.expect.epoch, testCase.band.time)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`${testCase.id}: epoch ${state.epoch} vs anchor ${testCase.expect.epoch}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
testCase.expect.reference_frame !== undefined &&
|
|
205
|
+
state.referenceFrame !== testCase.expect.reference_frame
|
|
206
|
+
) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`${testCase.id}: reference_frame ${state.referenceFrame} but the corpus ` +
|
|
209
|
+
`declares ${testCase.expect.reference_frame}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
checked += 1;
|
|
213
|
+
}
|
|
214
|
+
return `${checked} corpus anchors reproduced within band`;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- Tier C — physics invariants (corpus-declared applicability) --------
|
|
219
|
+
const mu = corpus?.conformance?.mu;
|
|
220
|
+
if (invariants.has("vis-viva-closure")) {
|
|
221
|
+
if (!Number.isFinite(mu)) {
|
|
222
|
+
checks.push(
|
|
223
|
+
gap(
|
|
224
|
+
"tierC/vis-viva-closure",
|
|
225
|
+
"C",
|
|
226
|
+
"corpus declares vis-viva-closure but carries no conformance.mu",
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
await run("tierC/vis-viva-closure", "C", true, (driver) => {
|
|
231
|
+
for (const testCase of corpus.cases) {
|
|
232
|
+
const { elements: caseElements, julianDate } = testCase.params;
|
|
233
|
+
driver.initFromOmm([caseElements]);
|
|
234
|
+
const { state } = driver.propagate(julianDate, 0);
|
|
235
|
+
const [x, y, z] = state.position;
|
|
236
|
+
const [vx, vy, vz] = state.velocity;
|
|
237
|
+
let speed;
|
|
238
|
+
if (state.referenceFrame === ReferenceFrame.ECEF) {
|
|
239
|
+
// Undo the Earth-rotation term to recover inertial magnitudes;
|
|
240
|
+
// magnitudes are frame-independent under a pure rotation.
|
|
241
|
+
const vxi = vx - EARTH_ROTATION_RATE * y;
|
|
242
|
+
const vyi = vy + EARTH_ROTATION_RATE * x;
|
|
243
|
+
speed = Math.hypot(vxi, vyi, vz);
|
|
244
|
+
} else {
|
|
245
|
+
speed = Math.hypot(vx, vy, vz);
|
|
246
|
+
}
|
|
247
|
+
const radius = Math.hypot(x, y, z);
|
|
248
|
+
const n =
|
|
249
|
+
(caseElements.meanMotionRevPerDay * 2 * Math.PI) / SECONDS_PER_DAY;
|
|
250
|
+
const a = Math.cbrt(mu / (n * n));
|
|
251
|
+
const energy = (speed * speed) / 2 - mu / radius;
|
|
252
|
+
const expected = -mu / (2 * a);
|
|
253
|
+
const relative = Math.abs((energy - expected) / expected);
|
|
254
|
+
if (!(relative < 1e-6)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`${testCase.id}: specific energy ${energy} vs -mu/2a ${expected} ` +
|
|
257
|
+
`(rel ${relative.toExponential(3)}) — the orbit adjudicated itself and lost`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return `${corpus.cases.length} states close vis-viva at rel < 1e-6`;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
} else if (corpus) {
|
|
265
|
+
checks.push(
|
|
266
|
+
gap(
|
|
267
|
+
"tierC/vis-viva-closure",
|
|
268
|
+
"C",
|
|
269
|
+
"corpus does not declare vis-viva-closure applicable to this module's model",
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
} else {
|
|
273
|
+
checks.push(
|
|
274
|
+
gap("tierC/vis-viva-closure", "C", "no corpus — model invariants not declared"),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (invariants.has("period-closure")) {
|
|
279
|
+
await run("tierC/period-closure", "C", true, (driver) => {
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
let closed = 0;
|
|
282
|
+
for (const testCase of corpus.cases) {
|
|
283
|
+
const caseElements = testCase.params.elements;
|
|
284
|
+
if (seen.has(caseElements.noradCatId)) continue;
|
|
285
|
+
seen.add(caseElements.noradCatId);
|
|
286
|
+
driver.initFromOmm([caseElements]);
|
|
287
|
+
const periodDays = 1 / caseElements.meanMotionRevPerDay;
|
|
288
|
+
const start = caseElements.epochJd;
|
|
289
|
+
const a = driver.propagate(start, 0).state;
|
|
290
|
+
const b = driver.propagate(start + periodDays, 0).state;
|
|
291
|
+
// Compare RADIUS rather than the rotating-frame vector: the Earth has
|
|
292
|
+
// turned under the orbit in one period.
|
|
293
|
+
const ra = Math.hypot(...a.position);
|
|
294
|
+
const rb = Math.hypot(...b.position);
|
|
295
|
+
if (!(Math.abs(ra - rb) < 1e-3)) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`period closure for ${caseElements.noradCatId}: |r| went ${ra} -> ${rb}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
closed += 1;
|
|
301
|
+
}
|
|
302
|
+
return `${closed} element sets return to the same radius after one period`;
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
checks.push(
|
|
306
|
+
gap(
|
|
307
|
+
"tierC/period-closure",
|
|
308
|
+
"C",
|
|
309
|
+
corpus
|
|
310
|
+
? "corpus does not declare period-closure applicable to this module's model"
|
|
311
|
+
: "no corpus — model invariants not declared",
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- Tier C — ABI-behaviour invariants (always run) ---------------------
|
|
317
|
+
await run("tierC/determinism-byte-identity", "C", true, (driver) => {
|
|
318
|
+
const first = elements[0];
|
|
319
|
+
const jd = first.epochJd + 0.01;
|
|
320
|
+
driver.initFromOmm([first]);
|
|
321
|
+
const a = driver.propagate(jd, 0).bytes;
|
|
322
|
+
const b = driver.propagate(jd, 0).bytes;
|
|
323
|
+
if (Buffer.compare(Buffer.from(a), Buffer.from(b)) !== 0) {
|
|
324
|
+
throw new Error("two identical calls diverged — determinism is compared as BYTES");
|
|
325
|
+
}
|
|
326
|
+
// Determinism must survive the lifecycle, not just the call.
|
|
327
|
+
driver.destroy();
|
|
328
|
+
driver.initFromOmm([first]);
|
|
329
|
+
const c = driver.propagate(jd, 0).bytes;
|
|
330
|
+
if (Buffer.compare(Buffer.from(a), Buffer.from(c)) !== 0) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
"output changed after destroy + re-ingest — state leaked across the lifecycle",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return "byte-identical across repeated calls and a destroy/re-ingest round trip";
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await run("tierC/frame-flags-reserved-declared", "C", true, (driver) => {
|
|
339
|
+
const first = elements[0];
|
|
340
|
+
driver.initFromOmm([first]);
|
|
341
|
+
const { state } = driver.propagate(first.epochJd, 0);
|
|
342
|
+
const declaredFrames = new Set(Object.values(ReferenceFrame));
|
|
343
|
+
if (!declaredFrames.has(state.referenceFrame)) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`reference_frame ${state.referenceFrame} is not a declared ReferenceFrame member — ` +
|
|
346
|
+
"the harness refuses undeclared frame vocabulary",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const corpusFrame = corpus?.cases?.[0]?.expect?.reference_frame;
|
|
350
|
+
if (corpusFrame !== undefined && state.referenceFrame !== corpusFrame) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`the module declares frame ${state.referenceFrame} but its own corpus pins ` +
|
|
353
|
+
`${corpusFrame} — a frame declaration that contradicts the module's corpus is ` +
|
|
354
|
+
"the silently-wrong-numbers defect the harness exists to prevent",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if ((state.flags & StateFlags.VALID) !== StateFlags.VALID) {
|
|
358
|
+
throw new Error("VALID flag not set on a successful propagation");
|
|
359
|
+
}
|
|
360
|
+
if (state.reserved.some((byte) => byte !== 0)) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
"the three IDL-reserved bytes at reference_frame+1..3 must be zero; non-zero " +
|
|
363
|
+
"means the writer assigned reference_frame directly instead of the generated " +
|
|
364
|
+
"setter, and a consumer reading a 32-bit word there sees garbage",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return `frame ${state.referenceFrame} declared, VALID set, reserved bytes zero`;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await run("tierC/batch-single-agreement", "C", true, (driver) => {
|
|
371
|
+
const batchElements = elements.slice(0, Math.min(4, elements.length));
|
|
372
|
+
driver.initFromOmm(batchElements);
|
|
373
|
+
const jd = batchElements[0].epochJd + 0.01;
|
|
374
|
+
const batch = driver.propagateBatch(jd, batchElements.length);
|
|
375
|
+
if (batch.status !== ErrorCode.OK) {
|
|
376
|
+
throw new Error(`propagate_batch status ${batch.status}`);
|
|
377
|
+
}
|
|
378
|
+
for (let index = 0; index < batchElements.length; index += 1) {
|
|
379
|
+
const single = driver.propagate(jd, index).state;
|
|
380
|
+
for (const axis of [0, 1, 2]) {
|
|
381
|
+
if (batch.states[index].position[axis] !== single.position[axis]) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`entity ${index}: batch and single disagree on position[${axis}] — ` +
|
|
384
|
+
"the batch path is not the same physics",
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return `${batchElements.length} entities agree exactly between batch and single paths`;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await run("tierC/typed-refusals", "C", true, (driver) => {
|
|
393
|
+
const probe = driver.propagate(2460000.5, 0);
|
|
394
|
+
if (probe.status !== ErrorCode.NOT_INITIALIZED) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`propagating before ingest returned ${probe.status}, not NOT_INITIALIZED ` +
|
|
397
|
+
`(${ErrorCode.NOT_INITIALIZED}) — a bad index must be distinguishable from an ` +
|
|
398
|
+
"uninitialized module or the host cannot place the failure on the degradation ladder",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
driver.initFromOmm([elements[0]]);
|
|
402
|
+
const badIndex = driver.propagate(2460000.5, 99);
|
|
403
|
+
if (badIndex.status !== ErrorCode.BAD_ENTITY_INDEX) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`an out-of-range entity index returned ${badIndex.status}, not ` +
|
|
406
|
+
`BAD_ENTITY_INDEX (${ErrorCode.BAD_ENTITY_INDEX})`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const hyperbolic = driver.initFromOmm([{ ...elements[0], eccentricity: 1.5 }]);
|
|
410
|
+
if (!(hyperbolic < 0)) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
"e >= 1 was accepted at ingest — a physically impossible result is a refusal, " +
|
|
413
|
+
"not an output propagated into confident nonsense",
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const retrograde = driver.initFromOmm([
|
|
417
|
+
{ ...elements[0], meanMotionRevPerDay: -1 },
|
|
418
|
+
]);
|
|
419
|
+
if (!(retrograde < 0)) {
|
|
420
|
+
throw new Error("a non-positive mean motion was accepted at ingest");
|
|
421
|
+
}
|
|
422
|
+
return "NOT_INITIALIZED, BAD_ENTITY_INDEX and unphysical-ingest refusals all typed";
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await run("tierC/create-returns-handle", "C", true, (driver) => {
|
|
426
|
+
driver.initFromOmm([elements[0]]);
|
|
427
|
+
const handle = driver.ingestOne(elements[1] ?? { ...elements[0], noradCatId: 1 });
|
|
428
|
+
if (handle !== 1) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`ingest returned ${handle}, not the handle it assigned — "the entity I just ` +
|
|
431
|
+
'created is count-1" is the race the harness exists to kill (finding §4.4)',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (driver.entityCount() !== 2) {
|
|
435
|
+
throw new Error(`entity count is ${driver.entityCount()}, expected 2`);
|
|
436
|
+
}
|
|
437
|
+
const roundTrip = driver.propagate(2460000.5, handle);
|
|
438
|
+
if (roundTrip.status !== ErrorCode.OK) {
|
|
439
|
+
throw new Error(`the returned handle does not propagate (status ${roundTrip.status})`);
|
|
440
|
+
}
|
|
441
|
+
return "ingest returns its own handle and the handle works";
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ---- Tier 4 — lifecycle -------------------------------------------------
|
|
445
|
+
const cycleRecords = (count) =>
|
|
446
|
+
Array.from({ length: count }, (_, index) => ({
|
|
447
|
+
...elements[0],
|
|
448
|
+
noradCatId: (elements[0].noradCatId ?? 25544) + index,
|
|
449
|
+
meanAnomalyDeg: ((elements[0].meanAnomalyDeg ?? 0) + index) % 360,
|
|
450
|
+
}));
|
|
451
|
+
|
|
452
|
+
function cycle(driver, entityCount) {
|
|
453
|
+
const records = cycleRecords(entityCount);
|
|
454
|
+
const ingested = driver.initFromOmm(records);
|
|
455
|
+
if (ingested !== entityCount) {
|
|
456
|
+
throw new Error(`mid-cycle ingest accepted ${ingested}/${entityCount} records`);
|
|
457
|
+
}
|
|
458
|
+
const batch = driver.propagateBatch(elements[0].epochJd + 0.01, entityCount);
|
|
459
|
+
if (batch.status !== ErrorCode.OK) {
|
|
460
|
+
throw new Error(`propagate_batch failed mid-cycle (status ${batch.status})`);
|
|
461
|
+
}
|
|
462
|
+
driver.destroy();
|
|
463
|
+
if (driver.entityCount() !== 0) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
"destroy left entities behind — the module is holding state it said it released",
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await run("tier4/lifecycle-leak", "4", true, (driver) => {
|
|
471
|
+
for (let i = 0; i < leak.warmupCycles; i += 1) cycle(driver, leak.entities);
|
|
472
|
+
const baselineBytes = driver.memoryBytes();
|
|
473
|
+
for (let i = 0; i < leak.measureCycles; i += 1) cycle(driver, leak.entities);
|
|
474
|
+
const growth = driver.memoryBytes() - baselineBytes;
|
|
475
|
+
if (growth !== 0) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`linear memory grew by ${growth} bytes across ${leak.measureCycles} identical ` +
|
|
478
|
+
`ingest/propagate/destroy cycles (baseline ${baselineBytes}) — destroy that is ` +
|
|
479
|
+
`real reaches a steady state; this leaks ~${(growth / leak.measureCycles).toFixed(1)} ` +
|
|
480
|
+
"bytes per cycle",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return `zero page growth across ${leak.measureCycles} cycles of ${leak.entities} entities`;
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
await run("tier4/destroy-idempotent", "4", true, (driver) => {
|
|
487
|
+
driver.destroy();
|
|
488
|
+
driver.destroy();
|
|
489
|
+
if (driver.entityCount() !== 0) {
|
|
490
|
+
throw new Error("double destroy left a non-zero entity count");
|
|
491
|
+
}
|
|
492
|
+
const dead = driver.propagate(elements[0].epochJd, 0);
|
|
493
|
+
if (dead.status !== ErrorCode.NOT_INITIALIZED) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`a destroyed module answered ${dead.status} instead of refusing with ` +
|
|
496
|
+
"NOT_INITIALIZED — it is reading freed state",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (driver.initFromOmm([elements[0]]) !== 1) {
|
|
500
|
+
throw new Error("module did not come back cleanly after destroy");
|
|
501
|
+
}
|
|
502
|
+
if (driver.propagate(elements[0].epochJd, 0).status !== ErrorCode.OK) {
|
|
503
|
+
throw new Error("post-destroy re-ingest does not propagate");
|
|
504
|
+
}
|
|
505
|
+
return "destroy is idempotent, refuses typed, and the module comes back cleanly";
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await run("tier4/leak-metric-negative-control", "4", true, (driver) => {
|
|
509
|
+
for (let i = 0; i < 5; i += 1) cycle(driver, 64);
|
|
510
|
+
const baselineBytes = driver.memoryBytes();
|
|
511
|
+
let leaked = 0;
|
|
512
|
+
while (driver.memoryBytes() === baselineBytes) {
|
|
513
|
+
driver.alloc(1 << 16);
|
|
514
|
+
leaked += 1;
|
|
515
|
+
if (leaked >= 100000) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
"allocating 64 KiB blocks never grew linear memory — the leak metric " +
|
|
518
|
+
"cannot move, so the leak test above is measuring nothing",
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return `deliberate leak moved the metric after ${leaked} allocations — the gate has been seen to fail`;
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
return checks;
|
|
526
|
+
}
|