Skip to content

PR-09: parity frames CK API (#480)#509

Open
charliecreates[bot] wants to merge 1 commit intocharlie/issue-454-v2-pr09-error-state-group08from
charlie/issue-454-v2-pr10-frames-ck-group09
Open

PR-09: parity frames CK API (#480)#509
charliecreates[bot] wants to merge 1 commit intocharlie/issue-454-v2-pr09-error-state-group08from
charlie/issue-454-v2-pr10-frames-ck-group09

Conversation

@charliecreates
Copy link
Contributor

Stack step PR-09 in the issue #454 migration chain.

@github-actions
Copy link

orrery preview: https://pr-509.orrery-c4f.pages.dev/

Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The native CSPICE runner additions are substantial, but there are correctness and safety gaps: frames.ckcov has a concrete stack buffer overflow risk when intervalCount > 256, and ckgp/ckgpav depend on implicit, potentially non-deterministic selection of instrument id and coverage interval. Additionally, frames.ckupf currently behaves as load+unload by path rather than unloading an existing handle, which can diverge from real API semantics. Finally, the new v2 YAML specs don’t assert result shapes (empty properties), reducing the parity suite’s ability to catch contract regressions.

Additional notes (1)
  • Readability | packages/parity-checking/specs/methods/frames/ckgpav@v2.yml:1-22
    ckgp and ckgpav specs pass MGS_SPACECRAFT as the ref argument. In CSPICE, ref is typically a reference frame name (e.g., J2000, MGS_SPACECRAFT, etc.), so this may be fine—but these tests currently rely on the runner’s implicit choice of inst and sclkdp/tol (computed from coverage). If the chosen instrument’s frame does not match ref, you can get SPICE errors or found=false in ways that are hard to diagnose.

At minimum, the runner should surface chosen inst and sclkdp to make failures actionable.

Summary of changes

What changed

Parity coverage & catalogs

  • Removed CK-related frames methods from the parity denylist (frames.ckcov, frames.ckgp, frames.ckgpav, frames.cklpf, frames.ckobj, frames.ckupf) in:
    • packages/parity-checking/catalogs/parity-denylist.json
    • packages/parity-checking/catalogs/parity-denylist.ts
  • Updated completeness baselines to reflect the new coverage:
    • BASELINE_CANONICAL_METHOD_COVERAGE: 143 → 149
    • MAX_BASELINE_DENYLIST_SIZE: 30 → 24

Native CSPICE runner: new CK frames calls

  • Added new CallIds and string dispatch mapping for CK frames functions in packages/parity-checking/native/src/cspice_runner.c:
    • ckgp, ckgpav, cklpf, ckupf, ckobj, ckcov
  • Implemented JSON argument parsing and result serialization for these calls.

TS runner dispatch

  • Added tspiceRunner dispatch entries for CK frames methods and their short aliases (frames.ckgp + ckgp, etc.) in packages/parity-checking/src/runners/tspiceRunner.ts.

Method specs & workflow

  • Added v2 parity method specs:
    • packages/parity-checking/specs/methods/frames/{ckcov,ckgp,ckgpav,cklpf,ckobj,ckupf}@v2.yml
  • Added a legacy workflow fixture pack:
    • packages/parity-checking/workflows/legacy/frames.ck.basic@v1.yml

Tests updated

  • Updated expected counts in:
    • packages/parity-checking/test/integration/completenessGuard.test.ts
    • packages/parity-checking/test/parity.e2e.test.ts
    • packages/parity-checking/test/parityScenarioCoverage.test.ts

Comment on lines +5584 to +5915
case CALL_CKGP: {
if (tokens[argsTok].size < 2) {
write_error_json_ex(
"invalid_args",
"frames.ckgp expects args[0]=string ckPath args[1]=string ref",
NULL,
NULL,
NULL,
NULL);
goto done;
}

const int pathTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
const int refTok = jsmn_get_array_elem(tokens, argsTok, 1, tokenCount);
if (pathTok < 0 || pathTok >= tokenCount || tokens[pathTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args", "frames.ckgp expects args[0] to be a string", NULL, NULL, NULL, NULL);
goto done;
}
if (refTok < 0 || refTok >= tokenCount || tokens[refTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args", "frames.ckgp expects args[1] to be a string", NULL, NULL, NULL, NULL);
goto done;
}

char *path = NULL;
char *ref = NULL;

strDetail[0] = '\0';
const jsmn_strdup_err_t pathErr =
jsmn_strdup(input, &tokens[pathTok], &path, strDetail, sizeof(strDetail));
if (pathErr != JSMN_STRDUP_OK) {
if (pathErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

strDetail[0] = '\0';
const jsmn_strdup_err_t refErr =
jsmn_strdup(input, &tokens[refTok], &ref, strDetail, sizeof(strDetail));
if (refErr != JSMN_STRDUP_OK) {
free(path);
if (refErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

SPICEINT_CELL(ids, 128);
ckobj_c(path, &ids);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
free(path);
free(ref);
write_error_json("SPICE error in ckobj (frames.ckgp setup)", shortMsg, longMsg,
traceMsg);
goto done;
}

const SpiceInt nIds = card_c(&ids);
if (nIds <= 0) {
free(path);
free(ref);
write_error_json_ex(
"invalid_args",
"frames.ckgp expected args[0] CK path to contain at least one object id",
NULL,
NULL,
NULL,
NULL);
goto done;
}

const SpiceInt inst = ((const SpiceInt *)ids.data)[0];

SPICEDOUBLE_CELL(cover, 512);
ckcov_c(path, inst, SPICEFALSE, "SEGMENT", 0.0, "SCLK", &cover);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
free(path);
free(ref);
write_error_json("SPICE error in ckcov (frames.ckgp setup)", shortMsg, longMsg,
traceMsg);
goto done;
}

const SpiceInt intervalCount = wncard_c(&cover);
if (intervalCount < 1) {
free(path);
free(ref);
write_error_json_ex(
"invalid_args",
"frames.ckgp expected args[0] CK path to have at least one SCLK coverage interval",
NULL,
NULL,
NULL,
NULL);
goto done;
}

SpiceDouble left = 0.0;
SpiceDouble right = 0.0;
wnfetd_c(&cover, 0, &left, &right);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
free(path);
free(ref);
write_error_json("SPICE error in wnfetd (frames.ckgp setup)", shortMsg, longMsg,
traceMsg);
goto done;
}

const SpiceDouble sclkdp = (left + right) / 2.0;
SpiceDouble tol = (right - left) / 2.0;
if (tol < 1.0) {
tol = 1.0;
}

SpiceDouble cmat[3][3];
SpiceDouble clkout = 0.0;
SpiceBoolean found = SPICEFALSE;
ckgp_c(inst, sclkdp, tol, ref, cmat, &clkout, &found);
free(path);
free(ref);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
write_error_json("SPICE error in ckgp", shortMsg, longMsg, traceMsg);
goto done;
}

if (found != SPICETRUE) {
fputs("{\"ok\":true,\"result\":{\"found\":false}}\n", stdout);
goto done;
}

fputs("{\"ok\":true,\"result\":{\"found\":true,\"cmat\":", stdout);
json_print_mat3_rowmajor(cmat);
fputs(",\"clkout\":", stdout);
fprintf(stdout, "%.17g", (double)clkout);
fputs("}}\n", stdout);
goto done;
}

case CALL_CKGPAV: {
if (tokens[argsTok].size < 2) {
write_error_json_ex(
"invalid_args",
"frames.ckgpav expects args[0]=string ckPath args[1]=string ref",
NULL,
NULL,
NULL,
NULL);
goto done;
}

const int pathTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
const int refTok = jsmn_get_array_elem(tokens, argsTok, 1, tokenCount);
if (pathTok < 0 || pathTok >= tokenCount || tokens[pathTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args", "frames.ckgpav expects args[0] to be a string", NULL, NULL, NULL, NULL);
goto done;
}
if (refTok < 0 || refTok >= tokenCount || tokens[refTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args", "frames.ckgpav expects args[1] to be a string", NULL, NULL, NULL, NULL);
goto done;
}

char *path = NULL;
char *ref = NULL;

strDetail[0] = '\0';
const jsmn_strdup_err_t pathErr =
jsmn_strdup(input, &tokens[pathTok], &path, strDetail, sizeof(strDetail));
if (pathErr != JSMN_STRDUP_OK) {
if (pathErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

strDetail[0] = '\0';
const jsmn_strdup_err_t refErr =
jsmn_strdup(input, &tokens[refTok], &ref, strDetail, sizeof(strDetail));
if (refErr != JSMN_STRDUP_OK) {
free(path);
if (refErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

SPICEINT_CELL(ids, 128);
ckobj_c(path, &ids);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
free(path);
free(ref);
write_error_json("SPICE error in ckobj (frames.ckgpav setup)", shortMsg, longMsg,
traceMsg);
goto done;
}

const SpiceInt nIds = card_c(&ids);
if (nIds <= 0) {
free(path);
free(ref);
write_error_json_ex(
"invalid_args",
"frames.ckgpav expected args[0] CK path to contain at least one object id",
NULL,
NULL,
NULL,
NULL);
goto done;
}

const SpiceInt inst = ((const SpiceInt *)ids.data)[0];

SPICEDOUBLE_CELL(cover, 512);
ckcov_c(path, inst, SPICEFALSE, "SEGMENT", 0.0, "SCLK", &cover);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
free(path);
free(ref);
write_error_json("SPICE error in ckcov (frames.ckgpav setup)", shortMsg, longMsg,
traceMsg);
goto done;
}

const SpiceInt intervalCount = wncard_c(&cover);
if (intervalCount < 1) {
free(path);
free(ref);
write_error_json_ex(
"invalid_args",
"frames.ckgpav expected args[0] CK path to have at least one SCLK coverage interval",
NULL,
NULL,
NULL,
NULL);
goto done;
}

SpiceDouble left = 0.0;
SpiceDouble right = 0.0;
wnfetd_c(&cover, 0, &left, &right);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
free(path);
free(ref);
write_error_json("SPICE error in wnfetd (frames.ckgpav setup)", shortMsg, longMsg,
traceMsg);
goto done;
}

const SpiceDouble sclkdp = (left + right) / 2.0;
SpiceDouble tol = (right - left) / 2.0;
if (tol < 1.0) {
tol = 1.0;
}

SpiceDouble cmat[3][3];
SpiceDouble av[3] = {0.0, 0.0, 0.0};
SpiceDouble clkout = 0.0;
SpiceBoolean found = SPICEFALSE;
ckgpav_c(inst, sclkdp, tol, ref, cmat, av, &clkout, &found);
free(path);
free(ref);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
write_error_json("SPICE error in ckgpav", shortMsg, longMsg, traceMsg);
goto done;
}

if (found != SPICETRUE) {
fputs("{\"ok\":true,\"result\":{\"found\":false}}\n", stdout);
goto done;
}

fputs("{\"ok\":true,\"result\":{\"found\":true,\"cmat\":", stdout);
json_print_mat3_rowmajor(cmat);
fputs(",\"av\":", stdout);
json_print_double_array(av, 3);
fputs(",\"clkout\":", stdout);
fprintf(stdout, "%.17g", (double)clkout);
fputs("}}\n", stdout);
goto done;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CALL_CKGP/CALL_CKGPAV silently pick the first object id returned by ckobj_c and the first coverage interval (wnfetd_c(..., 0, ...)). That makes results dependent on CK contents/order and can be non-deterministic across kernels or even across SPICE versions if ordering changes. It also hides the case where a CK contains multiple instruments with different coverage.

This is especially risky for parity since it can pass while validating the wrong instrument/interval.

Suggestion

Make instrument/interval selection explicit and deterministic. Options:

  1. Extend API args to accept inst and/or sclkdp (and possibly tol), avoiding introspection entirely.

  2. If keeping introspection, at least:

  • sort ids (or choose min id) deterministically,
  • choose the largest interval or the midpoint of the union of all intervals, and
  • return inst and chosen interval in the JSON result for transparency.

For example:

  • pick inst = ((SpiceInt*)ids.data)[0] only after ckobj_c output is ordered deterministically.
  • compute left/right by iterating all intervals and selecting the widest.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing deterministic selection and returning the chosen inst + interval metadata.

Comment on lines +5973 to +6027
case CALL_CKUPF: {
if (tokens[argsTok].size < 1) {
write_error_json_ex("invalid_args", "frames.ckupf expects args[0] to be a string", NULL, NULL, NULL, NULL);
goto done;
}

const int pathTok = jsmn_get_array_elem(tokens, argsTok, 0, tokenCount);
if (pathTok < 0 || pathTok >= tokenCount || tokens[pathTok].type != JSMN_STRING) {
write_error_json_ex("invalid_args", "frames.ckupf expects args[0] to be a string", NULL, NULL, NULL, NULL);
goto done;
}

char *path = NULL;
strDetail[0] = '\0';
const jsmn_strdup_err_t pathErr =
jsmn_strdup(input, &tokens[pathTok], &path, strDetail, sizeof(strDetail));
if (pathErr != JSMN_STRDUP_OK) {
if (pathErr == JSMN_STRDUP_INVALID) {
write_error_json_ex("invalid_request", "Invalid JSON string escape",
strDetail[0] ? strDetail : NULL, NULL, NULL, NULL);
} else {
write_error_json("Out of memory", NULL, NULL, NULL);
}
goto done;
}

SpiceInt handle = 0;
cklpf_c(path, &handle);
free(path);

if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
write_error_json("SPICE error in cklpf (frames.ckupf setup)", shortMsg, longMsg,
traceMsg);
goto done;
}

ckupf_c(handle);
if (failed_c() == SPICETRUE) {
char shortMsg[1841];
char longMsg[1841];
char traceMsg[1841];
capture_spice_error(shortMsg, sizeof(shortMsg), longMsg, sizeof(longMsg), traceMsg,
sizeof(traceMsg));
write_error_json("SPICE error in ckupf", shortMsg, longMsg, traceMsg);
goto done;
}

fputs("{\"ok\":true,\"result\":null}\n", stdout);
goto done;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CALL_CKUPF does not actually "unload" a CK handle provided by the caller; instead it loads the CK again via cklpf_c(path, &handle) and immediately calls ckupf_c(handle). This changes the semantics to "load+unload", not "unload".

That may be intentional for parity convenience, but it no longer tests the real API surface/behavior (e.g., unloading an existing handle) and could mask handle-lifetime bugs in callers.

Suggestion

Align parity behavior with the real ckupf contract:

  • Accept handle as an integer argument and call ckupf_c(handle) directly.
  • If you need a convenience helper, introduce a separate parity-only method like frames.ckupfFromPath (or keep frames.cklpf returning a handle so tests can call ckupf).

This will make the parity runner validate the same lifecycle contract users rely on.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit switching frames.ckupf to accept a numeric handle and updating the spec/tests accordingly.

Comment on lines +1 to +22
schemaVersion: 2
manifest:
id: methods/frames/ckgp@v2
kind: method
contract:
contractMethod: frames.ckgp
canonicalMethod: frames.ckgp
aliases: []
result:
type: object
properties: {}
setup:
kernels:
- $FIXTURES/mgs-minimal
workflow:
steps:
- op: invokeLegacyCall
cases:
- id: frames.ckgp.mgs-minimal
args:
- $FIXTURES/mgs-minimal/mgs_hga_hinge_v2.bc
- MGS_SPACECRAFT
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new v2 method specs declare result.type: object with empty properties: {} for calls that clearly return structured data (ckgp returns found, cmat, clkout; ckgpav adds av; ckcov returns idcode, intervals, etc.).

With empty result schemas, you’re not asserting output shape at all, which reduces the value of parity tests and can let breaking JSON contract changes slip through.

Suggestion

Define the expected result schema properties for each method spec so parity verifies structure, not just that the call succeeds.

For frames.ckgp, something like:

  • found: boolean
  • when found: true: cmat: number[9] (row-major), clkout: number

For frames.ckcov:

  • idcode: integer
  • intervals: number[] with even length
  • level: string, timsys: string, needav: boolean

If conditional schemas are supported, use oneOf/if/then to model found.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit filling in schemas for all six new v2 specs.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 22, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant