Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -246,18 +246,17 @@ jobs:
- name: Operational-readiness gate
run: python tools/validate_operational_readiness.py apps/web/data/latest-state.json

# ADR 0005 round-trip: the Rust CLI reads a real observation report, corrects the
# activity forecast through the Kalman primitive, and the emitted Assimilation-mode
# snapshot must pass the shared Python validators — Rust reading, Rust writing,
# Python judging, one contract.
# Use deterministic synthetic reports from the actual Python producer:
# fresh activity must assimilate, while missing/stale activity must retain
# the prior. The app's historical report is not guaranteed to admit analysis.
# Both modes must pass shared schema/readiness validators after Rust transport.
- name: Assimilation round-trip (Rust -> shared validators)
shell: bash
env:
SOL_REQUIRE_CLI: "1"
run: |
cargo run --quiet --locked -p solar-cli -- simulate --steps 48 --dt-hours 1 --seed 42 \
--observations apps/web/data/latest-observations.json --out /tmp/assimilated.json
grep -q '"mode": "Assimilation"' /tmp/assimilated.json
python tools/validate_snapshot.py /tmp/assimilated.json
python tools/validate_operational_readiness.py /tmp/assimilated.json
cargo build --quiet --locked -p solar-cli
PYTHONPATH=tools python -m unittest discover -s tests/python -p test_activity_observation.py -v

- name: Regenerate deterministic solar-cycle series
run: python tools/generate_series.py
Expand Down
94 changes: 50 additions & 44 deletions BUILD_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,47 +66,53 @@ Patched after reviewing NWS Service Change Notice 26-21:
- Extended cached snapshots with `observed-context.v1` so SWPC solar regions, sunspot rows, GOES flare rows, and cycle indices can tune the research fixture while preserving provenance and normalized-unit caveats.
- Updated the web UI to show daily feed status separately from snapshot readiness.

## Publishing `solar-ephemeris` to crates.io

> **Status:** `solar-ephemeris` is live on crates.io —
> <https://crates.io/crates/solar-ephemeris>. **0.1.1** was the first publish
> (2026-07-20); **0.2.0** (2026-07-20) is current and carries the 108-star
> bright-star catalogue. The steps below drive subsequent version bumps; the
> guard skips any version already on crates.io, so a scheduled run is a no-op
> until the workspace version changes.

The `solar-ephemeris` crate is released to crates.io by the
`.github/workflows/publish-crate.yml` workflow. It runs on a weekly schedule
(Mondays 07:30 UTC) and on demand via **workflow_dispatch**. A guard step queries
crates.io and skips the publish when the current workspace version is already
live, so scheduled runs stay green between releases — publishing is driven by
version bumps, not by the clock.

### One-time setup: the `CARGO_REGISTRY_TOKEN` secret

The publish step authenticates with a crates.io API token stored in the repository
secret `CARGO_REGISTRY_TOKEN`. Without it the workflow still runs, but the publish
step fails to authenticate.

1. On crates.io, go to **Account Settings -> API Tokens -> New Token**.
- Scopes: `publish-new` (needed for the first-ever publish of the crate name)
and `publish-update` (later version bumps).
- Crate scope: restrict the token to `solar-ephemeris` (least privilege).
- Optionally set an expiry, and copy the token (shown only once).
2. Add it to the repo at **Settings -> Secrets and variables -> Actions ->
New repository secret**: name it exactly `CARGO_REGISTRY_TOKEN`, value = the
token. (Or locally: `gh secret set CARGO_REGISTRY_TOKEN --repo Protonmatter/sol`.)

The token owner must be an owner of the `solar-ephemeris` crate on crates.io once
it is published, or later version-bump publishes return 403.

### Cutting a release

1. Bump `version` in the workspace `[workspace.package]` table in the root
`Cargo.toml` (the crate inherits it via `version.workspace = true`), and update
`Cargo.lock`.
2. Land it on `master` through the normal CI-gated PR flow.
3. Either wait for the next weekly run or trigger it now: **Actions -> "Publish
solar-ephemeris to crates.io" -> Run workflow**. The guard detects the new
version is not yet on crates.io and publishes it with `cargo publish -p
solar-ephemeris --locked`.
## Registry history and current package qualification

Reviewed: 2026-09-28. The historical registry publications remain valid:
[0.1.1](https://docs.rs/solar-ephemeris/0.1.1/solar_ephemeris/) was first published
on 2026-07-20, followed by
[0.2.0](https://docs.rs/solar-ephemeris/0.2.0/solar_ephemeris/) that day with the
108-star bright-star catalogue. The
[crates.io API record](https://crates.io/api/v1/crates/solar-ephemeris) confirms
both versions exist and are not yanked as of this review. The crates.io browser
pages returned HTTP 404 during the review; that does not establish registry absence.
The links above open the versioned API documentation. Published versions and the
current checkout's v3 contract are separate artifacts.

The current [package workflow](.github/workflows/publish-crate.yml) runs only on
**workflow_dispatch**, with a required exact 40-character `source_sha`. Its retained
name is "Publish solar-ephemeris to crates.io", but its implemented action is
**package qualification with publication held**. There is no weekly schedule,
registry credential input or registry publication step. A version bump does not
authorize publication.

### Qualify an exact source candidate

Use a clean checkout of the reviewed commit. The local package checks are:

```bash
cargo test -p solar-ephemeris --locked
cargo package --list -p solar-ephemeris --locked
cargo publish --dry-run -p solar-ephemeris --locked
```

The dry run can access the registry index; it does not upload the crate. Retain its
log and the resulting `.crate` bytes for package-content and notice review. For a
separately authorized hosted qualification, supply the reviewed commit explicitly:

```bash
gh workflow run publish-crate.yml --repo Protonmatter/sol -f source_sha=REVIEWED_40_CHARACTER_COMMIT_SHA
```

The workflow checks out that SHA, tests/packages it, records exact-source package
evidence with `tools/release_crate.py`, and uploads
`qualified-crate-candidate-RUN_ID-RUN_ATTEMPT` with 90-day retention. After successful
qualification/upload, its final hold step deliberately exits **1** to make the
publication boundary visible. Earlier failures may prevent candidate creation;
inspect the exact run's steps and artifacts before claiming qualification.

Actual publication remains held pending exact-source CI, accepted package
digest/contents/notices and separately protected registry authority. Do not add a
registry secret to bypass this hold. Any future publication path also needs
post-publication checksum verification, because publishing can repackage source.
See [release delivery](docs/RELEASE_DELIVERY.md) for the governing evidence boundaries.
10 changes: 6 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Review round (all findings from the Codex PR reviewer, each verified before acti

### Published

- First [crates.io](https://crates.io/crates/solar-ephemeris) release of the
- First crates.io release ([versioned API documentation](https://docs.rs/solar-ephemeris/0.1.1/solar_ephemeris/)) of the
**`solar-ephemeris`** crate — a zero-dependency VSOP2013 + ELP-MPP02 + TOP2013
ephemeris and topocentric sky engine, validated against JPL Horizons to arcsecond
class. Install with `cargo add solar-ephemeris`.
Expand All @@ -253,7 +253,9 @@ Review round (all findings from the Codex PR reviewer, each verified before acti

- Add `.github/workflows/publish-crate.yml` — scheduled (weekly) and on-demand
`cargo publish -p solar-ephemeris`, guarded to skip versions already on crates.io so
scheduled runs stay green between releases.
scheduled runs stay green between releases. This describes the workflow at that
release; the current workflow performs manual exact-source qualification with
publication held, as documented in [build notes](BUILD_NOTES.md).
- Harden `.github/workflows/ephemeris-accuracy.yml` — SHA-pin all actions, add a job
timeout, and build with `--locked`, matching the repo's other workflows.
- Add `.github/workflows/docs.yml` + `tools/validate_docs.py` — offline Markdown
Expand All @@ -264,5 +266,5 @@ Review round (all findings from the Codex PR reviewer, each verified before acti
- Document the crates.io release process in `BUILD_NOTES.md`.

[Unreleased]: https://github.com/Protonmatter/sol/compare/master...HEAD
[0.2.0]: https://crates.io/crates/solar-ephemeris/0.2.0
[0.1.1]: https://crates.io/crates/solar-ephemeris/0.1.1
[0.2.0]: https://docs.rs/solar-ephemeris/0.2.0/solar_ephemeris/
[0.1.1]: https://docs.rs/solar-ephemeris/0.1.1/solar_ephemeris/
2 changes: 1 addition & 1 deletion apps/web/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function engineReady() {
// Read the bytes immediately and copy them out before any further wasm call.
export function simulateSnapshot({ seed = 42, steps = 24, dtHours = 1, activity = 0.9, lon = 72, lat = 36 } = {}) {
validateSolarRequest({seed,steps,dtHours,activity,lon,lat});
if (!wasmExports) throw new Error("engine not loaded");
if (!engineReady()) throw new Error("engine not loaded");
const ptr = wasmExports.simulate(seed, steps, dtHours, activity, lon, lat);
const len = wasmExports.result_len();
const view = new Uint8Array(wasmExports.memory.buffer, ptr, len);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/js/bodyData.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const AU_KM = 149597870.7;
/** @type {Record<string, BodyPhys>} */
export const BODY = {
Sun: {
radiusKm: 695700, polarKm: 695700, massKg: 1.9885e30, densityGcm3: 1.408,
radiusKm: SUN_RADIUS_KM, polarKm: SUN_RADIUS_KM, massKg: 1.9885e30, densityGcm3: 1.408,
gravity: 274.0, escapeKms: 617.5, rotationHours: 609.12 /* 25.38 d Carrington sidereal */, tiltDeg: 7.25,
poleRaDeg: 286.13, poleDecDeg: 63.87, poleRaDotDegPerCty: 0.0, poleDecDotDegPerCty: 0.0, w0Deg: 84.176, wDotDegPerDay: 14.1844,
magDipoleEarth: 0, magnetosphere: true,
Expand Down
9 changes: 6 additions & 3 deletions apps/web/js/orrery.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import {
galShear, sunGalacticPos, buildGalaxyModel, buildGalObjectList,
buildCatalogStarsGalactic, buildNeighbourhoodModel, neighbourhoodPos,
} from "./orreryGalaxy.js?v=dcca6290db";
import { renderDetail, renderMoonDetail, renderSmallDetail, updateLiveDetailFacts, updateDetailAppearance } from "./orreryDetail.js?v=dcca6290de";
import { renderDetail, renderMoonDetail, renderSmallDetail, updateSmallDetailFacts, updateLiveDetailFacts, updateDetailAppearance } from "./orreryDetail.js?v=dcca6290de";
import { renderStarDetail } from "./starDetail.js?v=dcca6290db";
import { buildEarthMapSliced, buildFeatureMap } from "./surfacemap.js?v=dcca6290db";
import { resolveDisplayRadii, moonGuideVisible } from "./displayGeometry.js?v=dcca6290db";
Expand Down Expand Up @@ -1212,13 +1212,14 @@ function buildSmallBuffers() {

// Recompute every small-body marker for the current `renderUnix`, and upload them as one point buffer.
function rebuildSmallBodies() {
if (!gl) return; // context lost — keep the previous CPU-side list until restore
smallBodies = [];
const jy2k = timeJy2k(state.renderUnix);
// `el` carries the source record so the detail card can show the orbit (a, e, i, period).
for (const b of DWARFS) smallBodies.push({ name: b.n, pos: bodyXYZ(b, jy2k), col: b.col, kind: "dwarf", note: b.note, el: b });
for (const c of COMETS) smallBodies.push({ name: c.n, pos: bodyXYZ(c, jy2k), col: c.col, kind: "comet", note: c.note, el: c });
for (const p of PROBES) smallBodies.push({ name: p.n, pos: probeXYZ(p), col: p.col, kind: "probe", note: p.note, el: p });
updateSmallDetailFacts(smallBodies.find(body=>body.name===state.selected));
if (!gl) return; // CPU facts still follow explicit time changes during context loss.
// The CPU-side list above is ALWAYS built: the Focus dropdown and the detail panel resolve
// small bodies through it even while the drawn layer is hidden. Only the GPU markers are
// gated on the checkbox (drawSmallBodies/pick/labels each gate themselves).
Expand Down Expand Up @@ -3269,7 +3270,9 @@ async function enterOrreryInner() {
// frame corrupting the Sun surface's layout for the rest of the session.
canvas.style.display = "";
try {
const {unix}=validateSystemRequest({unix:effectiveBaseUnix() + state.simElapsed});
// Leaving suspends the scene clock. Reentry/Retry refreshes its metadata, not
// its accepted time; only first entry and explicit Now/time inputs use wall time.
const {unix}=validateSystemRequest({unix:state.bodies.length===9 ? state.renderUnix : effectiveBaseUnix() + state.simElapsed});
const [,snapshot]=await Promise.all([loadSkyEngine(),requestSystemSnapshot(unix)]);
if(!state.active||generation!==systemGeneration)return;
state.bodies=snapshot.bodies.map(body=>({...body}));state.renderUnix=unix;state.metadataUnix=unix;state.engineError="";lastFullSnapshot=performance.now();
Expand Down
22 changes: 19 additions & 3 deletions apps/web/js/orreryDetail.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,9 @@ export function renderSmallDetail(s) {
const add = (k, v) => {
if (v == null) return;
const dt = document.createElement("dt"); dt.textContent = k;
const dd = document.createElement("dd"); dd.textContent = v; dl.append(dt, dd);
const dd = document.createElement("dd"); dd.textContent = v; dd.dataset.metric = k; dl.append(dt, dd);
};
const distAU = Math.hypot(s.pos[0], s.pos[1], s.pos[2]);
add("Distance from Sun", `${distAU.toFixed(2)} AU · light ${(distAU * 8.317 / 60).toFixed(1)} h`);
add("Distance from Sun", smallBodyDistanceLabel(s));
const el = s.el || {};
if (s.kind !== "probe" && el.a != null) {
add("Semi-major axis", `${el.a.toFixed(2)} AU`);
Expand All @@ -132,6 +131,23 @@ export function renderSmallDetail(s) {
host.appendChild(card);
}

function smallBodyDistanceLabel(body) {
const distAU = Math.hypot(...body.pos);
return `${distAU.toFixed(2)} AU · light ${(distAU * 8.317 / 60).toFixed(1)} h`;
}

// Called when current-epoch markers are rebuilt, including animation frames.
// Updating only the metric retains open source disclosures and keyboard focus.
export function updateSmallDetailFacts(body) {
if (!body) return;
const host = document.getElementById("orreryDetail");
if (host?.querySelector(".system-detail > strong")?.textContent !== body.name) return;
const value = smallBodyDistanceLabel(body);
for (const node of host.querySelectorAll("[data-metric]")) {
if (node.getAttribute("data-metric") === "Distance from Sun" && node.textContent !== value) node.textContent = value;
}
}

// Render the facts card for `name` into #orreryDetail. `live` is the body's row from the
// current system snapshot (distances/speed/phase/magnitude/equilibrium temp), or undefined.
export function renderDetail(name, live, appearanceState = {}) {
Expand Down
17 changes: 10 additions & 7 deletions crates/solar-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,15 @@ fn assess_observations(
.ok()
.map(|assessment| (value, assessment))
});
let observed_activity = context.map(|(_, assessment)| assessment.activity_index);
// Freshness is judged from the report's own generation-time evaluation (age vs the
// per-feed limits), so the run is reproducible from the file alone: the gain is the
// fraction of feeds that were fresh when the report was written.
let (fresh, total) = context
.map(|(_, assessment)| (assessment.fresh, assessment.total))
let activity = context.and_then(|(value, assessment)| {
snapshot_validation::assess_activity_observation(value, assessment, &evidence)
});
let observed_activity = activity.map(|assessment| assessment.activity_index);
// Evaluate the report's recorded ages, never the wall clock. New descriptors
// include only fresh activity contributors; unrelated feed freshness cannot
// admit a missing/stale signal. Historical simple reports retain their gain.
let (fresh, total) = activity
.map(|assessment| (assessment.fresh, assessment.total))
.unwrap_or((0, 0));
let freshness_gain = if total == 0 {
0.0
Expand All @@ -213,7 +216,7 @@ fn assess_observations(
&& report_source_mode.is_some();
if !usable {
let reason = if observed_activity.is_none() {
"no valid observed_context activity/freshness values"
"no attributable fresh activity observation; fixture defaults cannot be assimilated"
} else if evidence.is_empty() {
"no complete observation frames with attributable provenance"
} else if report_source_mode.is_none() {
Expand Down
Loading
Loading