Validation
Numerical Validation
Tests live in src/test/ and check numerical results against external reference data with per-column
tolerances. Each data set records its source, how it was generated and why its tolerance was chosen, so it can be
rebuilt and audited. src/test/jieqi_golden_test.cpp is a complete example; AGENTS.md gives the
conventions. External references include:
- JPL Horizons: apparent positions of the Sun, the Moon and Mercury through Pluto, and Jieqi crossings,
collected or replayed by
moon_horizons_crawler.py,planet_horizons_crawler.py,pluto_horizons_crawler.py,sun_equatorial_horizons_crawler.pyandsun_jieqi_golden_crawler.pyinstatistics/. - Hong Kong Observatory almanac: the published Jieqi clock times for 2022–2028. The whole calculation chain
must agree within 60 seconds, a margin that mostly absorbs the almanac's own rounding to the minute. Implemented
by
automation/jieqi_table.py; the entry point is./checks.py --jieqi-table. - ytliu0's ChineseCalendar: an independent lunar-year table pinned to a commit, used to validate the
pre-generated algorithm; see
src/test/lunar/algo3_ytliu0_golden_test.cpp. - ΔT observations: the NASA eclipse ΔT table, USNO observations, and Stephenson & Morrison. They give UT1 ↔ TT
conversion a baseline that does not depend on this library's fitted models; see
src/test/astro/julian_day_test.cpp. - Sunrise and sunset references: USNO / NOAA / JPL DE, with a tolerance of ±2 minutes.
- Swiss Ephemeris 2.10.3bfinal: formula-level and date-level reference values for the Equal, Whole Sign and
Placidus house systems, generated by
statistics/house_swiss_crawler.py.
statistics/ holds the data-collection scripts and evaluation notebooks. Install their dependencies in a virtual
environment with python -m pip install -r Requirements-statistics.txt. The references
keep the original source links.
Equatorial reference layers
equatorial_query_golden_test.cpp checks two different
questions without changing existing golden values or tolerances:
- Same-input conformance: 120 frozen ecliptic longitude/latitude pairs are checked against directions computed
independently with SOFA IAU1980 true obliquity and a Cartesian rotation. Another 28 rows check the four Meeus node definitions.
Wrapped RA and declination each use a
1e-9degree ceiling. These checks do not establish physical accuracy. - Physical regression: 120 retained Horizons Q2 rows, twelve per target, cover sampled epochs in 1892–2026. The per-column ceilings below include the existing providers' differences from Horizons. RA is wrapped without a cosine-of-declination factor. These are retained-sample regression ceilings, not global error bounds.
| Target | RA ceiling (arcseconds) | Declination ceiling (arcseconds) |
|---|---|---|
| Sun | 0.4 | 0.2 |
| Moon | 6 | 4 |
| Mercury, Venus | 0.2 | 0.1 |
| Mars | 0.3 | 0.1 |
| Jupiter | 0.5 | 0.25 |
| Saturn | 0.4 | 0.2 |
| Uranus | 1.5 | 0.5 |
| Neptune | 2 | 1 |
| Pluto | 1 | 0.5 |
The test records the requested TT inputs, source versions and generation recipe. Its live-provider composition checks are separate from the frozen-input expectations. Nodes are mathematical directions with no distance; their reference rows check the stated definition, not an observational or osculating-node ephemeris. The reference values are stored in the test; neither the tests nor the runtime require an external ephemeris.
The two generated EventBounds table extents remain fixed in ephemeris_event_bounds.hpp; their size and
target order are asserted against the shared roster. They are retained numerical data, not a second
hand-maintained target roster.
Chart Snapshot Replay Margins
docs/examples/chart_snapshot/data.json stores one complete chart snapshot.
The automation tests replay it through the C++ core, the installed Python package through the C ABI, and the
JavaScript package tests through WASM. Each field is held to one of five absolute margins in the fixture's
replay_margins object. The Python and JavaScript replay helpers both read those values from the stored payload.
These are replay acceptance margins. They bound how far a fresh run may drift from the stored values; they do not say how close the positions are to the sky. That evidence comes from the external references above.
The thresholds come from the snapshot test added for #300. Longitude, the four axes and the house cusps are compared around the circle, so values on either side of 0°/360° count as close.
They are separate from the margins in
bindings/python/test/consumer/golden_replay.py. Those were
measured: they are the largest residuals of the release wheels on four platforms (#217), and they apply to
toolbox/bindings_golden.json, which holds no chart data.
Comparing Values Between Two Commits
A change that should not move any number, such as a refactor or an include cleanup, can be checked by running the same inputs through the old and the new code and comparing every value and every exception. This is evidence for one change, not a retained check: it is not committed, CI does not rerun it, and it compares the code with its own earlier version, not with an external reference. If a comparison turns up behavior worth pinning, add a test with reference data as described under Numerical Validation.
Write a probe: a small C++ program that calls the functions the change touches over a fixed sample of inputs and
prints one line per call. Print values with %.17g, which tells every binary64 value apart, and catch exceptions
so their type and message are printed too. For example:
#include <cstdio>
#include <exception>
#include <typeinfo>
#include "delta_t.hpp"
int main() {
using astro::delta_t::Model;
for (const auto model : { Model::ALGO1, Model::ALGO2, Model::ALGO3, Model::ALGO4, Model::ALGO5 }) {
for (int year = -4100; year <= 3100; year += 25) {
std::printf("model=%d year=%d ", static_cast<int>(model), year);
try {
std::printf("value %.17g\n", astro::delta_t::compute(model, year));
} catch (const std::exception& e) {
std::printf("throw %s: %s\n", typeid(e).name(), e.what());
}
}
}
}
Keep the probe outside the source tree. From the root of the checkout that holds the change, extract the base commit to a temporary directory, compile the probe against both trees with one compiler and one set of options, and compare the outputs:
base=main # the commit or branch to compare against
probe=/path/to/probe.cpp
compile() { clang++ -std=c++23 -O2 "$@"; } # the same compiler and options for both sides
work=$(mktemp -d) &&
mkdir "$work/base" &&
git archive -o "$work/base.tar" "$base" &&
tar -x -f "$work/base.tar" -C "$work/base" &&
compile -I"$work/base/src/astro" -I"$work/base/src/calendar" -I"$work/base/src/util" "$probe" -o "$work/probe-base" &&
compile -Isrc/astro -Isrc/calendar -Isrc/util "$probe" -o "$work/probe-head" &&
"$work/probe-base" > "$work/base.txt" &&
"$work/probe-head" > "$work/head.txt" &&
test -s "$work/head.txt" &&
diff "$work/base.txt" "$work/head.txt" &&
wc -l < "$work/head.txt"
The second build uses the working tree, uncommitted edits included. git archive does not touch the checkout,
and every output goes to the temporary directory. If a header moved between the two commits, change the include
paths for that side only. A row count as the last line means every sampled call returned the same value or threw
the same exception type with the same message.
The result has two limits, and a report of it should state both:
- Sample: it holds only for the inputs the probe sampled. Name them and give the row count.
- Toolchain: it holds only for that compiler, standard library, set of options and platform. Another toolchain can round the last bits differently. Name the compiler, its version and the options.
A pull request that cites such a comparison should also name the base commit.
Jieqi JSON Table Export
An application that only needs Jieqi order and spacing, and does not want to link the library, can use
toolbox/jieqi_table.py to export query_jieqi_moment as a static JSON table (#164). Build with
python3 project.py --cmake --build first, then run from the source root:
python3 toolbox/jieqi_table.py
python3 toolbox/jieqi_table.py -o build/jieqi.json --start-year 2000 --end-year 2031
The first command writes the default 1950–2051 table to standard output, 24 × 102 = 2448 entries. The second
chooses an output file and a year window; the years must lie within [401, 9999]. The output contract:
- Schema
celestial-calendar/jieqi-table@2replaces@1. Each entry is{year, idx, name_zh, jd_ut1, iso_ut1}.idxcounts from Lichun (立春) = 0 and follows the ABI'sto_indexorder;name_zhcomes from the ABI'scelestial_get_jieqi_name, so the mapping can be checked. - Entries are sorted by actual moment, strictly increasing. Within a Gregorian year the index order is 22, 23, 0, …, 21: Xiaohan (小寒) and Dahan (大寒) come first, and entries are not sorted by index.
yearis the owning year passed to the query. In the default window all 24 crossings of each year fall in the same Gregorian year, and the check verifies this. That does not always hold for high years: Xiaohan of 9999 falls on 9998-12-31, soyeardiffers from the year insideiso_ut1.- The default table keeps 2051 as a trailing year, so every moment in 1950–2050 still has its successor in the table.
jd_ut1is a floating-point Julian day on model UT1, used for ordering and time intervals.iso_ut1displays that civil moment asYYYY-MM-DDTHH:MM:SS.sss, truncating sub-millisecond parts. It has noZor offset and must not be passed to a UTC parser. The misleadingunix_msandiso_utcfields are removed, without compatibility aliases.- No UT1-to-UTC conversion or leap-second table participates in this export. This also applies before 1972,
near leap seconds and for future dates; the output does not predict future UTC or observed DUT1.
models.delta_trecords the fixed Algo5 model identity and description from the loaded library, using its kind/identity pair rather than a registry index; a selectable-model default change does not change Jieqi UT1.models.leap_secondsis null. Time scales explains the distinction. - The local build writes a
.provenance.jsonreceipt beside the resolved library. It records production source-file digests, their combined snapshot digest, the CMake configuration digest and the artifact digest. When the receipt is missing or stale,project.py --buildrebuilds from scratch before recording a new one. A failed build or a source/configuration change during the build leaves no valid receipt. - Export verifies the receipt, current production source snapshot and exact artifact bytes before loading a
private copy so the loader uses those verified bytes, not a cached earlier library. Missing metadata, stale
sources or a different binary fail the command rather than produce a
table with guessed provenance. Raw
cmake --builddoes not establish this receipt; rebuild throughproject.py --buildbefore exporting. The receipt is a record of a locally trusted build, not a signed attestation. source.commitcomes from the build receipt, not the export-time HEAD. It is recorded only when the source files match the commit's exact bytes. Dirty builds and clean checkouts with converted bytes (such as CRLF) record null instead;source.snapshot_sha256still identifies its source bytes, andsource.artifact_sha256identifies its library. Packaging-timebuild_info.jsoncannot substitute for this evidence. The output has no generation timestamp; repeated exports of the same verified artifact and source snapshot are byte-identical.
./checks.py --jieqi-table checks this contract, the HKO almanac baseline, and an independent recomputation by
statistics/common.py.