From bf21a06d7d8e786b373f5458f75e161b873c9238 Mon Sep 17 00:00:00 2001 From: Jack Steam Date: Fri, 13 Nov 2026 10:42:39 -0600 Subject: [PATCH 1/4] feat: add pinned native and Wasm independent benchmark QC --- .github/workflows/novas-qc.yml | 77 +++++++++++++++++++ examples/novas_qc_capture.rs | 75 +++++++++++++++++++ tools/verification/novas-lock.json | 13 ++++ tools/verification/novas-qc.md | 54 ++++++++++++++ tools/verification/novas-qc.mjs | 114 +++++++++++++++++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 .github/workflows/novas-qc.yml create mode 100644 examples/novas_qc_capture.rs create mode 100644 tools/verification/novas-lock.json create mode 100644 tools/verification/novas-qc.md create mode 100644 tools/verification/novas-qc.mjs diff --git a/.github/workflows/novas-qc.yml b/.github/workflows/novas-qc.yml new file mode 100644 index 0000000..6a4d8b6 --- /dev/null +++ b/.github/workflows/novas-qc.yml @@ -0,0 +1,77 @@ +name: Explicit independent benchmark QC +on: + workflow_dispatch: + inputs: + dataset_url: + description: Authorized HTTPS chart dataset URL (blank runs offset-only QC) + required: false + type: string + dataset_sha256: + description: Required SHA-256 when supplying a dataset URL + required: false + type: string +permissions: + contents: read +jobs: + independent-qc: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + defaults: + run: + working-directory: library + steps: + - uses: actions/checkout@v4 + with: + path: library + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.14' + - run: rustup toolchain install 1.96.0 --profile minimal --target wasm32-unknown-unknown + - name: Fetch the pinned benchmark source + run: | + revision=$(node -p 'JSON.parse(require("fs").readFileSync("tools/verification/novas-lock.json")).revision') + git init ../novas-reference + git -C ../novas-reference remote add origin https://github.com/AstrologyEngine/novas-benchmark.git + git -C ../novas-reference fetch --depth=1 origin "$revision" + git -C ../novas-reference checkout --detach FETCH_HEAD + - run: npm run setup + working-directory: novas-reference + - name: Prepare optional dataset and Wasm tool + env: + DATASET_URL: ${{ inputs.dataset_url }} + DATASET_SHA256: ${{ inputs.dataset_sha256 }} + run: | + mkdir -p target + if [ -n "$DATASET_URL" ]; then + node -e 'if (!/^[a-f0-9]{64}$/.test(process.env.DATASET_SHA256)) throw new Error("Dataset SHA-256 required")' + curl --fail --location --proto '=https' --proto-redir '=https' "$DATASET_URL" --output target/qc-dataset.bin + node -e 'const fs=require("fs"),crypto=require("crypto");if(crypto.createHash("sha256").update(fs.readFileSync("target/qc-dataset.bin")).digest("hex")!==process.env.DATASET_SHA256)throw new Error("Dataset checksum mismatch")' + cargo install wasm-bindgen-cli --version 0.2.122 --locked --root target/qc-tools + fi + - name: Run QC (numerical failures fail this job) + env: + DATASET_URL: ${{ inputs.dataset_url }} + run: | + JPL_KERNEL=../novas-reference/data/de440s.bsp node ../novas-reference/src/server.mjs > target/benchmark.log 2>&1 & + service_pid=$! + trap 'kill "$service_pid" 2>/dev/null || true' EXIT + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8788/health > /dev/null; then break; fi + sleep 1 + done + if [ -n "$DATASET_URL" ]; then + node tools/verification/novas-qc.mjs http://127.0.0.1:8788 target/novas-qc-report.json target/qc-dataset.bin --wasm + else + node tools/verification/novas-qc.mjs http://127.0.0.1:8788 target/novas-qc-report.json + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: independent-qc-evidence + path: | + library/target/novas-qc-report.json + library/target/benchmark.log + if-no-files-found: warn diff --git a/examples/novas_qc_capture.rs b/examples/novas_qc_capture.rs new file mode 100644 index 0000000..626874e --- /dev/null +++ b/examples/novas_qc_capture.rs @@ -0,0 +1,75 @@ +// Copyright (c) Jack Asher +// SPDX-License-Identifier: MIT + +#[cfg(feature = "generation")] +fn main() -> Result<(), Box> { + use astrology_engine::tooling::{nutation::iau2000b_full, time::AstroTime}; + use astrology_engine::{ + calculate_chart, lahiri_ayanamsa, ChartInput, Ephemeris, Epoch, HouseSystem, Location, + ZodiacConfiguration, + }; + use serde::Deserialize; + use serde_json::json; + use std::io::{self, Read}; + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Case { + id: String, + utc: String, + zodiac: String, + chart: bool, + } + let data = std::env::args().nth(1).map(std::fs::read).transpose()?; + let ephemeris = data + .map(Ephemeris::parse) + .transpose() + .map_err(|e| format!("Dataset: {e:?}"))?; + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + let cases: Vec = serde_json::from_str(&input)?; + let mut output = Vec::with_capacity(cases.len()); + for case in cases { + let epoch: Epoch = case.utc.parse()?; + let time = AstroTime::from_tdb(epoch); + let nutation = iau2000b_full(time); + let reported = lahiri_ayanamsa(epoch)?; + let chart = if case.chart { + let eph = ephemeris + .as_ref() + .ok_or("Chart requested without dataset")?; + let configuration: ZodiacConfiguration = serde_json::from_value( + json!({"reference":case.zodiac,"divisions":"equal","ophiuchus":null}), + )?; + Some( + match calculate_chart( + eph, + ChartInput { + epoch, + location: Location { + latitude: 0.0, + longitude: 0.0, + }, + house_system: HouseSystem::WholeSign, + zodiac: configuration, + }, + ) { + Ok(value) => json!({"value":value}), + Err(error) => json!({"error":error.to_string()}), + }, + ) + } else { + None + }; + output.push(json!({"id":case.id,"utc":case.utc,"zodiac":case.zodiac, + "effectiveJdTT":time.tt+2451545.0,"effectiveJdUT1":time.ut+2451545.0, + "physicalJdTT":epoch.to_jde_tt_days(),"etSeconds":epoch.to_et_seconds(), + "reportedLahiri":reported,"nutationArcseconds":{"longitude":nutation.dpsi,"obliquity":nutation.deps},"chart":chart})); + } + println!("{}", serde_json::to_string(&output)?); + Ok(()) +} +#[cfg(not(feature = "generation"))] +fn main() { + eprintln!("Use --features generation for the QC capture adapter"); + std::process::exit(2); +} diff --git a/tools/verification/novas-lock.json b/tools/verification/novas-lock.json new file mode 100644 index 0000000..1b4a191 --- /dev/null +++ b/tools/verification/novas-lock.json @@ -0,0 +1,13 @@ +{ + "repository": "https://github.com/AstrologyEngine/novas-benchmark", + "provenance": { + "engine": "SuperNOVAS", + "version": "1.8.0", + "calceph": "5.0.1", + "erfa": "2.0.1", + "serviceHash": "dbf8acf8b61277d03e10b8205b5432bff6943fde60b1814b9c44a94200fe8b8b", + "dependencyHash": "68c569532019a16d4fe01721a942b75eb546c58b4bbdd964d38fdc6e7668c694", + "dataHash": "c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2" + }, + "revision": "d8eb9016e5338c00fe409fa29e4a48a61ecef1cf" +} diff --git a/tools/verification/novas-qc.md b/tools/verification/novas-qc.md new file mode 100644 index 0000000..4aac4db --- /dev/null +++ b/tools/verification/novas-qc.md @@ -0,0 +1,54 @@ +# Independent benchmark quality control + +This QC is based on `fix/lahiri-full-nutation-import` at +`9f84a6e9ec815fb52418f26d98a6ccdd78d688dd`. It adds tooling only: library +calculations, fitted datasets and regression fixtures are unchanged. + +Start the independent MIT benchmark from the exact commit recorded in +`novas-lock.json`, using its setup command and pinned JPL inputs. The runner +checks the service-source, dependency and dataset identities, and requires the +same native executable identity throughout the run. The benchmark and the +library must remain separate repositories. + +```sh +node tools/verification/novas-qc.mjs http://127.0.0.1:8788 target/offset-qc.json +node tools/verification/novas-qc.mjs http://127.0.0.1:8788 target/chart-qc.json /path/to/cheb.bin --wasm +``` + +The runner requires Rust 1.96.0. With `--wasm`, it also requires Bun, the wasm32 +Rust target and wasm-bindgen-cli 0.2.122. It uses `target/qc-tools/bin/wasm-bindgen` +if installed there, or WASM_BINDGEN/an executable on PATH. It rebuilds both the +native capture and Wasm adapter, records executable/glue hashes and a source +snapshot hash, and rejects source/dataset changes during the run. + +Exit 0 means all requested checks passed; 1 means completed numerical failures; +2 means setup, transport, identity or contract failure. Reports retain failure +rows. Do not change formulas, thresholds or frozen fixtures merely to make QC +pass. The manually dispatched `Explicit independent benchmark QC` workflow can +run offset-only, or native plus Wasm charts with an explicitly SHA-pinned dataset. +It is a separate opt-in gate, not a required baseline PR check. + +## Scope and time conventions + +Offset-only covers 408 cases: a two-year grid over 1801–2199, 80 short-interval +2026 cases, and 128 deterministic held-out instants (seed 0x4e4f5641). +Original IAU 2000B longitude/obliquity and reported Lahiri offset use a fixed +one-microarcsecond tolerance. The source library's 77 terms and the benchmark's +ERFA routine share the same standard; this checks implementation and integration, +not independent evidence that the standard itself is correct. + +With a dataset, 375 additional chart cases cover 125 dates in 1851–2099 across +tropical, Lahiri and Fagan-Bradley. Eleven major/derived bodies supply longitude, +speed and declination; limits are one arcsecond and one arcsecond/day. The runner +uses captured effective library TT for matched offset checks, and the physical +TT corresponding to each UTC input for end-to-end body checks. The latter retain +time-model effects rather than silently correcting candidate values. + +The benchmark projects its full-nutation tropical positions with a matching +full-nutation offset. The library retains its five-term projection while reporting +the original 77-term offset. Neither is substituted for the other during QC. + +Reports explicitly set `fullChartCoverage: false`: houses, nodes, Ceres, Chiron +and True Sky are still outside this client. Whole Sign is used to obtain real +chart outputs, but its cusps are not yet validated. Unsupported coverage and +calculation errors are failures, never silently skipped successful evidence. diff --git a/tools/verification/novas-qc.mjs b/tools/verification/novas-qc.mjs new file mode 100644 index 0000000..95f34db --- /dev/null +++ b/tools/verification/novas-qc.mjs @@ -0,0 +1,114 @@ +// Copyright (c) Jack Asher +// SPDX-License-Identifier: MIT +import {readFileSync,writeFileSync,readdirSync,existsSync} from 'node:fs'; +import {execFileSync,spawnSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; +const main=async()=>{ +const root=resolve(import.meta.dirname,'../..'); +const [endpoint,reportPath,dataset,...flags]=process.argv.slice(2); +if(!endpoint||!reportPath||dataset?.startsWith('--')||flags.some(flag=>flag!=='--wasm'))throw new Error('Usage: node tools/verification/novas-qc.mjs URL REPORT [DATASET] [--wasm]'); +const useWasm=flags.includes('--wasm'); +const lock=JSON.parse(readFileSync(resolve(root,'tools/verification/novas-lock.json'))); +const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); +const sourceFiles=()=>[...readdirSync(resolve(root,'src'),{recursive:true}).filter(name=>name.endsWith('.rs')).map(name=>'src/'+name), + 'Cargo.toml','Cargo.lock','rust-toolchain.toml',...(existsSync(resolve(root,'build.rs'))?['build.rs']:[]),'examples/novas_qc_capture.rs','tools/verification/novas-qc.mjs','tools/verification/novas-lock.json', + 'examples/configurable-chart/build-wasm.ts','tools/ci/versions.ts','examples/configurable-chart/wasm/Cargo.toml','examples/configurable-chart/wasm/Cargo.lock', + ...readdirSync(resolve(root,'examples/configurable-chart/wasm/src'),{recursive:true}).filter(name=>name.endsWith('.rs')).map(name=>'examples/configurable-chart/wasm/src/'+name)]; +const sourceHash=()=>{const h=createHash('sha256');for(const file of sourceFiles().sort())h.update(file+'\0').update(readFileSync(resolve(root,file))).update('\0');return h.digest('hex');}; +const snapshotHash=sourceHash(); +const assert=(condition,message)=>{if(!condition)throw new Error(message);}; +const get=async(path,input)=>{ + const response=await fetch(new URL(path,endpoint),input?{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(input),signal:AbortSignal.timeout(20000)}:{signal:AbortSignal.timeout(20000)}); + assert(response.ok,`Benchmark HTTP ${response.status}`);return response.json(); +}; +const health=await get('/health'); +for(const [key,expected]of Object.entries(lock.provenance))assert(health.provenance?.[key]===expected,`Benchmark ${key} mismatch`); +const identity=JSON.stringify(health.provenance); +const checked=async(path,input)=>{const result=await get(path,input);assert(JSON.stringify(result.provenance)===identity,'Benchmark identity changed');assert(result.jdTT===input.jdTT&&result.zodiac===input.zodiac,'Reference input mismatch');return result;}; +const cases=[]; +const add=(utc,zodiac='lahiri',chart=false)=>cases.push({id:`case-${cases.length}`,utc,zodiac,chart}); +for(let year=1801;year<2200;year+=2)add(`${year}-06-15T12:00:00Z`); +for(let day=0;day<80;day++)add(new Date(Date.UTC(2026,0,1)+day*8.88*3600000).toISOString()); +let seed=0x4e4f5641; +for(let index=0;index<128;index++){ + seed=(Math.imul(seed,1664525)+1013904223)>>>0; + add(new Date(Date.UTC(1801,0,1)+(seed/2**32)*(Date.UTC(2199,0,1)-Date.UTC(1801,0,1))).toISOString()); +} +if(dataset)for(let index=0;index<125;index++)for(const zodiac of ['tropical','lahiri','fagan-bradley'])add(new Date(Date.UTC(1851+index*2,index%12,15,12)).toISOString(),zodiac,true); +const datasetBytes=dataset?readFileSync(resolve(dataset)):null; +const captured=spawnSync('cargo',['run','--quiet','--locked','--target-dir',resolve(root,'target/novas-qc'),'--features','generation','--example','novas_qc_capture','--',...(dataset?[resolve(dataset)]:[])],{cwd:root,input:JSON.stringify(cases),encoding:'utf8',maxBuffer:32*1024*1024}); +assert(captured.status===0,captured.stderr||'Native capture failed'); +const values=JSON.parse(captured.stdout); +assert(Array.isArray(values)&&values.length===cases.length,'Capture count mismatch'); +assert(new Set(values.map(value=>value.id)).size===cases.length,'Duplicate capture'); +let wasmCalculate,data,wasmArtifacts=null; +const nativeBinaryHash=hash(readFileSync(resolve(root,'target/novas-qc/debug/examples/novas_qc_capture'))); +if(dataset){data=readFileSync(resolve(dataset));assert(hash(data)===hash(datasetBytes),'Dataset changed during capture');} +if(useWasm){ + assert(data,'Wasm QC requires a dataset'); + const localBindgen=resolve(root,'target/qc-tools/bin/wasm-bindgen'); + const build=spawnSync('bun',['run','build:wasm'],{cwd:resolve(root,'examples/configurable-chart'),env:{...process.env,CARGO_TARGET_DIR:resolve(root,'examples/configurable-chart/wasm/target'),WASM_BINDGEN:process.env.WASM_BINDGEN??(existsSync(localBindgen)?localBindgen:'wasm-bindgen')},encoding:'utf8',maxBuffer:8*1024*1024}); + assert(build.status===0,build.stderr||'Wasm build failed'); + wasmArtifacts={wasmHash:hash(readFileSync(resolve(root,'examples/configurable-chart/engine/chart_bg.wasm'))),glueHash:hash(readFileSync(resolve(root,'examples/configurable-chart/engine/chart.js'))),rebuilt:true}; + const module=await import(pathToFileURL(resolve(root,'examples/configurable-chart/engine/chart.js'))); + await module.default({module_or_path:readFileSync(resolve(root,'examples/configurable-chart/engine/chart_bg.wasm'))});wasmCalculate=module.calculate; +} +const rows=[];const errors=[]; +const row=(id,path,actual,expected,tolerance,angular=false)=>{ + assert(Number.isFinite(actual)&&Number.isFinite(expected),`Nonfinite/missing ${id} ${path}`); + const delta=angular?((actual-expected+540)%360+360)%360-180:actual-expected; + rows.push({id,path,actual,expected,delta,tolerance,pass:Math.abs(delta)<=tolerance}); +}; +const supported=['SUN','MOON','MERCURY','VENUS','MARS','JUPITER','SATURN','URANUS','NEPTUNE','PLUTO','EARTH']; +for(let index=0;index{ + assert(chart.configuration.reference===input.zodiac,'Chart zodiac mismatch'); + assert(new Set(chart.bodies.map(body=>body.name)).size===chart.bodies.length,'Duplicate chart body'); + const physical=await checked('/v1/calculate',{jdTT:value.physicalJdTT,zodiac:input.zodiac,bodies:supported}); + for(const name of supported){ + const body=chart.bodies.find(body=>body.name===name);assert(body,`Missing ${name}`); + for(const field of ['longitude','declination','speed'])row(input.id,`${target}.endToEnd.${name}.${field}`,body[field],physical.bodies[name][field],1/3600,field==='longitude'); + } + const offset=await checked('/v1/offsets',{jdTT:value.effectiveJdTT,zodiac:input.zodiac}); + row(input.id,`${target}.matchedModel.reportedOffset`,chart.reference_offset_degrees,offset.offsets.reported,input.zodiac==='lahiri'?1e-6/3600:1/3600); + }; + await compareChart(chart,'native'); + if(wasmCalculate){ + let wasm; + try{wasm=JSON.parse(wasmCalculate(data,JSON.stringify({instant:{scale:'utc',value:input.utc},latitude:0,longitude:0,house_system:'whole-sign',configuration:{reference:input.zodiac,divisions:'equal',ophiuchus:null}})));} + catch(error){errors.push({id:input.id,target:'wasm',error:String(error)});continue;} + await compareChart(wasm,'wasm'); + } +} +assert(sourceHash()===snapshotHash,'Source changed during QC'); +const report={execution:{platform:process.platform,arch:process.arch,node:process.version,rust:execFileSync('rustc',['--version'],{cwd:root,encoding:'utf8'}).trim()},sourceHash:snapshotHash,nativeBinaryHash,wasmArtifacts,scope:dataset?'major-planets-and-reported-offsets':'reported-lahiri-and-original-nutation',fullChartCoverage:false, + omitted:['houses','nodes','Ceres','Chiron','True Sky'],cases:cases.length,chartCases:cases.filter(c=>c.chart).length,targets:useWasm?['native','wasm']:['native'], + pass:errors.length===0&&rows.every(row=>row.pass),rows,errors,benchmark:health.provenance, + sourceRevision:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(), + workingTreeDirty:execFileSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}).trim().length>0, + datasetHash:data?hash(data):null,heldOutSeed:'0x4e4f5641', + timePolicy:'reported offsets matched to captured library effective TT; end-to-end planetary outputs compared at physical TT corresponding to UTC'}; +writeFileSync(reportPath,JSON.stringify(report,null,2)+'\n'); +console.log(JSON.stringify({pass:report.pass,cases:report.cases,rows:rows.length,failures:rows.filter(row=>!row.pass).length,errors:errors.length})); +process.exitCode=report.pass?0:1; + +}; +try{await main();}catch(error){ + const failure={status:"setup-error",pass:false,error:error.message}; + console.error(JSON.stringify(failure)); + const reportPath=process.argv[3]; + if(reportPath&&!reportPath.startsWith("--")){try{writeFileSync(reportPath,JSON.stringify(failure,null,2)+"\n");}catch{/* Keep the original setup failure on stderr. */}} + process.exitCode=2; +} From e7c69bc4ab90263689378d5d06fe6a8bc18df354 Mon Sep 17 00:00:00 2001 From: Jack Steam Date: Fri, 13 Nov 2026 11:02:08 -0600 Subject: [PATCH 2/4] Extend independent native and Wasm QC to houses and angles --- examples/novas_qc_capture.rs | 10 +++++++--- tools/verification/novas-lock.json | 4 ++-- tools/verification/novas-qc.md | 12 +++++++++--- tools/verification/novas-qc.mjs | 29 ++++++++++++++++++++++------- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/examples/novas_qc_capture.rs b/examples/novas_qc_capture.rs index 626874e..64e9d69 100644 --- a/examples/novas_qc_capture.rs +++ b/examples/novas_qc_capture.rs @@ -18,6 +18,10 @@ fn main() -> Result<(), Box> { utc: String, zodiac: String, chart: bool, + latitude: f64, + longitude: f64, + #[serde(rename = "houseSystem")] + house_system: HouseSystem, } let data = std::env::args().nth(1).map(std::fs::read).transpose()?; let ephemeris = data @@ -46,10 +50,10 @@ fn main() -> Result<(), Box> { ChartInput { epoch, location: Location { - latitude: 0.0, - longitude: 0.0, + latitude: case.latitude, + longitude: case.longitude, }, - house_system: HouseSystem::WholeSign, + house_system: case.house_system, zodiac: configuration, }, ) { diff --git a/tools/verification/novas-lock.json b/tools/verification/novas-lock.json index 1b4a191..4140dc7 100644 --- a/tools/verification/novas-lock.json +++ b/tools/verification/novas-lock.json @@ -5,9 +5,9 @@ "version": "1.8.0", "calceph": "5.0.1", "erfa": "2.0.1", - "serviceHash": "dbf8acf8b61277d03e10b8205b5432bff6943fde60b1814b9c44a94200fe8b8b", + "serviceHash": "9adc4b0ac0559d393cfe9b4cbf97c95d9928dee70f2c792f6a7c11853a93bc1e", "dependencyHash": "68c569532019a16d4fe01721a942b75eb546c58b4bbdd964d38fdc6e7668c694", "dataHash": "c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2" }, - "revision": "d8eb9016e5338c00fe409fa29e4a48a61ecef1cf" + "revision": "3b264765bef051074c2d85331684dae2be5f39c3" } diff --git a/tools/verification/novas-qc.md b/tools/verification/novas-qc.md index 4aac4db..423620a 100644 --- a/tools/verification/novas-qc.md +++ b/tools/verification/novas-qc.md @@ -48,7 +48,13 @@ The benchmark projects its full-nutation tropical positions with a matching full-nutation offset. The library retains its five-term projection while reporting the original 77-term offset. Neither is substituted for the other during QC. -Reports explicitly set `fullChartCoverage: false`: houses, nodes, Ceres, Chiron -and True Sky are still outside this client. Whole Sign is used to obtain real -chart outputs, but its cusps are not yet validated. Unsupported coverage and +The chart sweep rotates Whole Sign, Equal and Placidus across latitudes +-65, -45, 0, 45 and 65 degrees, with varying east-positive longitude. It checks +all twelve cusps and four angle longitudes at captured effective TT and UT1, +plus requested/actual house-system identity. These are matched-time diagnostics; +they do not establish the library's UTC-to-UT1 model accuracy. Angle speeds +and declinations are not benchmarked. + +Reports explicitly set `fullChartCoverage: false`: polar library house QC, +nodes, Ceres, Chiron and True Sky are still outside this client. Unsupported coverage and calculation errors are failures, never silently skipped successful evidence. diff --git a/tools/verification/novas-qc.mjs b/tools/verification/novas-qc.mjs index 95f34db..3bb8a6a 100644 --- a/tools/verification/novas-qc.mjs +++ b/tools/verification/novas-qc.mjs @@ -26,9 +26,15 @@ const get=async(path,input)=>{ const health=await get('/health'); for(const [key,expected]of Object.entries(lock.provenance))assert(health.provenance?.[key]===expected,`Benchmark ${key} mismatch`); const identity=JSON.stringify(health.provenance); -const checked=async(path,input)=>{const result=await get(path,input);assert(JSON.stringify(result.provenance)===identity,'Benchmark identity changed');assert(result.jdTT===input.jdTT&&result.zodiac===input.zodiac,'Reference input mismatch');return result;}; +const checked=async(path,input)=>{const result=await get(path,input);assert(JSON.stringify(result.provenance)===identity,'Benchmark identity changed');assert(result.jdTT===input.jdTT&&result.zodiac===input.zodiac,'Reference input mismatch'); + if(input.houseSystem){ + assert(result.jdUT1===input.jdUT1&&result.location?.latitude===input.location.latitude&&result.location?.longitude===input.location.longitude&&result.geometry?.requestedSystem===input.houseSystem,'Reference geometry input mismatch'); + assert(Array.isArray(result.geometry.cusps)&&result.geometry.cusps.length===12&&result.geometry.cusps.every(Number.isFinite),'Reference cusp coverage mismatch'); + assert(result.geometry.angles&&Object.keys(result.geometry.angles).sort().join(',')==='ascendant,descendant,imumCoeli,midheaven'&&Object.values(result.geometry.angles).every(Number.isFinite),'Reference angle coverage mismatch'); + } + return result;}; const cases=[]; -const add=(utc,zodiac='lahiri',chart=false)=>cases.push({id:`case-${cases.length}`,utc,zodiac,chart}); +const add=(utc,zodiac='lahiri',chart=false,geometry={})=>cases.push({id:`case-${cases.length}`,utc,zodiac,chart,latitude:0,longitude:0,houseSystem:'whole-sign',...geometry}); for(let year=1801;year<2200;year+=2)add(`${year}-06-15T12:00:00Z`); for(let day=0;day<80;day++)add(new Date(Date.UTC(2026,0,1)+day*8.88*3600000).toISOString()); let seed=0x4e4f5641; @@ -36,7 +42,7 @@ for(let index=0;index<128;index++){ seed=(Math.imul(seed,1664525)+1013904223)>>>0; add(new Date(Date.UTC(1801,0,1)+(seed/2**32)*(Date.UTC(2199,0,1)-Date.UTC(1801,0,1))).toISOString()); } -if(dataset)for(let index=0;index<125;index++)for(const zodiac of ['tropical','lahiri','fagan-bradley'])add(new Date(Date.UTC(1851+index*2,index%12,15,12)).toISOString(),zodiac,true); +if(dataset)for(let index=0;index<125;index++)for(const zodiac of ['tropical','lahiri','fagan-bradley'])add(new Date(Date.UTC(1851+index*2,index%12,15,12)).toISOString(),zodiac,true,{latitude:[-65,-45,0,45,65][index%5],longitude:-175+(index%24)*15,houseSystem:['whole-sign','equal','placidus'][index%3]}); const datasetBytes=dataset?readFileSync(resolve(dataset)):null; const captured=spawnSync('cargo',['run','--quiet','--locked','--target-dir',resolve(root,'target/novas-qc'),'--features','generation','--example','novas_qc_capture','--',...(dataset?[resolve(dataset)]:[])],{cwd:root,input:JSON.stringify(cases),encoding:'utf8',maxBuffer:32*1024*1024}); assert(captured.status===0,captured.stderr||'Native capture failed'); @@ -81,25 +87,34 @@ for(let index=0;indexbody.name===name);assert(body,`Missing ${name}`); for(const field of ['longitude','declination','speed'])row(input.id,`${target}.endToEnd.${name}.${field}`,body[field],physical.bodies[name][field],1/3600,field==='longitude'); } + const geometry=await checked('/v1/calculate',{jdTT:value.effectiveJdTT,jdUT1:value.effectiveJdUT1,zodiac:input.zodiac,bodies:['SUN'],location:{latitude:input.latitude,longitude:input.longitude},houseSystem:input.houseSystem,polarHouses:'porphyry'}); + assert(chart.requested_house_system===input.houseSystem,'Requested house system mismatch'); + if(chart.house_system!==geometry.geometry.actualSystem)errors.push({id:input.id,target,error:'Actual house system mismatch',actual:chart.house_system,expected:geometry.geometry.actualSystem}); + assert(Array.isArray(chart.cusps)&&chart.cusps.length===12,'House cusp coverage mismatch'); + chart.cusps.forEach((cusp,i)=>row(input.id,`${target}.matchedTime.cusps.${i}`,cusp,geometry.geometry.cusps[i],1/3600,true)); + for(const [name,key]of Object.entries({ASCENDANT_SYMBOL:'ascendant',MIDHEAVEN:'midheaven',DESCENDANT:'descendant',IMUM_COELI:'imumCoeli'})){ + const body=chart.bodies.find(body=>body.name===name);assert(body,`Missing angle ${name}`); + row(input.id,`${target}.matchedTime.${name}.longitude`,body.longitude,geometry.geometry.angles[key],1/3600,true); + } const offset=await checked('/v1/offsets',{jdTT:value.effectiveJdTT,zodiac:input.zodiac}); row(input.id,`${target}.matchedModel.reportedOffset`,chart.reference_offset_degrees,offset.offsets.reported,input.zodiac==='lahiri'?1e-6/3600:1/3600); }; await compareChart(chart,'native'); if(wasmCalculate){ let wasm; - try{wasm=JSON.parse(wasmCalculate(data,JSON.stringify({instant:{scale:'utc',value:input.utc},latitude:0,longitude:0,house_system:'whole-sign',configuration:{reference:input.zodiac,divisions:'equal',ophiuchus:null}})));} + try{wasm=JSON.parse(wasmCalculate(data,JSON.stringify({instant:{scale:'utc',value:input.utc},latitude:input.latitude,longitude:input.longitude,house_system:input.houseSystem,configuration:{reference:input.zodiac,divisions:'equal',ophiuchus:null}})));} catch(error){errors.push({id:input.id,target:'wasm',error:String(error)});continue;} await compareChart(wasm,'wasm'); } } assert(sourceHash()===snapshotHash,'Source changed during QC'); -const report={execution:{platform:process.platform,arch:process.arch,node:process.version,rust:execFileSync('rustc',['--version'],{cwd:root,encoding:'utf8'}).trim()},sourceHash:snapshotHash,nativeBinaryHash,wasmArtifacts,scope:dataset?'major-planets-and-reported-offsets':'reported-lahiri-and-original-nutation',fullChartCoverage:false, - omitted:['houses','nodes','Ceres','Chiron','True Sky'],cases:cases.length,chartCases:cases.filter(c=>c.chart).length,targets:useWasm?['native','wasm']:['native'], +const report={execution:{platform:process.platform,arch:process.arch,node:process.version,rust:execFileSync('rustc',['--version'],{cwd:root,encoding:'utf8'}).trim()},sourceHash:snapshotHash,nativeBinaryHash,wasmArtifacts,scope:dataset?'major-planets-houses-and-reported-offsets':'reported-lahiri-and-original-nutation',fullChartCoverage:false, + omitted:['polar-library-house-QC','nodes','Ceres','Chiron','True Sky'],cases:cases.length,chartCases:cases.filter(c=>c.chart).length,targets:useWasm?['native','wasm']:['native'], pass:errors.length===0&&rows.every(row=>row.pass),rows,errors,benchmark:health.provenance, sourceRevision:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(), workingTreeDirty:execFileSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}).trim().length>0, datasetHash:data?hash(data):null,heldOutSeed:'0x4e4f5641', - timePolicy:'reported offsets matched to captured library effective TT; end-to-end planetary outputs compared at physical TT corresponding to UTC'}; + timePolicy:'reported offsets matched to captured library effective TT; end-to-end planetary outputs compared at physical TT corresponding to UTC; house geometry compared at captured effective TT and UT1'}; writeFileSync(reportPath,JSON.stringify(report,null,2)+'\n'); console.log(JSON.stringify({pass:report.pass,cases:report.cases,rows:rows.length,failures:rows.filter(row=>!row.pass).length,errors:errors.length})); process.exitCode=report.pass?0:1; From 80b1503ee9e0681f7fb21c13fb4c93e5cd7fdc27 Mon Sep 17 00:00:00 2001 From: Jack Steam Date: Fri, 13 Nov 2026 11:43:00 -0600 Subject: [PATCH 3/4] Gate osculating nodes and preserve polar house QC failures --- tools/verification/novas-lock.json | 4 +-- tools/verification/novas-qc-findings.md | 37 +++++++++++++++++++++++++ tools/verification/novas-qc.md | 26 +++++++++++++---- tools/verification/novas-qc.mjs | 33 +++++++++++++++------- 4 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 tools/verification/novas-qc-findings.md diff --git a/tools/verification/novas-lock.json b/tools/verification/novas-lock.json index 4140dc7..1177ef9 100644 --- a/tools/verification/novas-lock.json +++ b/tools/verification/novas-lock.json @@ -5,9 +5,9 @@ "version": "1.8.0", "calceph": "5.0.1", "erfa": "2.0.1", - "serviceHash": "9adc4b0ac0559d393cfe9b4cbf97c95d9928dee70f2c792f6a7c11853a93bc1e", + "serviceHash": "88679d674780b4c04fb0188b100447b6d333f7a95a83d89f91d93a9b0814246e", "dependencyHash": "68c569532019a16d4fe01721a942b75eb546c58b4bbdd964d38fdc6e7668c694", "dataHash": "c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2" }, - "revision": "3b264765bef051074c2d85331684dae2be5f39c3" + "revision": "818ccf73a21098a45bd3b0f3f4956bf9aca6c66f" } diff --git a/tools/verification/novas-qc-findings.md b/tools/verification/novas-qc-findings.md new file mode 100644 index 0000000..6dfbbab --- /dev/null +++ b/tools/verification/novas-qc-findings.md @@ -0,0 +1,37 @@ +# Independent QC findings + +This is a report of the pinned library branch and supplied immutable dataset, +not a change to calculation behavior or expected regression fixtures. + +Dataset SHA-256: +`06d4c26e589207bf479dbd217fafb3e4d6c559626425b156912e39a4d859487b`. +Reference: independent SuperNOVAS service revision in `novas-lock.json`. +Reproduce with the native/Wasm command in [the QC guide](novas-qc.md). + +The expanded sweep includes 831 cases, of which 423 request charts. It +completes 48,525 numerical checks without numerical failures. This includes +4,932 node checks, whose maximum absolute residual is 0.391780 arcseconds +(or arcseconds/day for rates), within the unchanged one-arcsecond limits. + +The overall QC gate **fails with exit 1**: twelve polar inputs are rejected +by both native and Wasm with the library's overlapping-house error. Each of +these four spacetime combinations is tested in tropical, Lahiri and +Fagan-Bradley, at longitude zero with Placidus: + +| UTC | Latitude | +| --- | ---: | +| 2026-03-20 18:00 | -85 | +| 2026-03-20 18:00 | -70 | +| 2026-03-20 06:00 | 70 | +| 2026-03-20 06:00 | 85 | + +Each target completes 411 charts and rejects 12, producing 24 recorded +calculation errors. The independent reference constructs an ordered Porphyry +fallback for these inputs. The report preserves that geometry alongside the +errors. No row is fabricated for an unavailable library chart, and native +failure does not prevent a Wasm attempt. + +The library already documents rejection of overlapping polar house sets. +These results expose that coverage limitation; they do not by themselves +justify changing its convention. Any library house change requires a separate +implementation and review. The benchmark gate remains strict in the meantime. diff --git a/tools/verification/novas-qc.md b/tools/verification/novas-qc.md index 423620a..b159a13 100644 --- a/tools/verification/novas-qc.md +++ b/tools/verification/novas-qc.md @@ -21,7 +21,7 @@ if installed there, or WASM_BINDGEN/an executable on PATH. It rebuilds both the native capture and Wasm adapter, records executable/glue hashes and a source snapshot hash, and rejects source/dataset changes during the run. -Exit 0 means all requested checks passed; 1 means completed numerical failures; +Exit 0 means all requested checks passed; 1 means numerical or chart-calculation failures; 2 means setup, transport, identity or contract failure. Reports retain failure rows. Do not change formulas, thresholds or frozen fixtures merely to make QC pass. The manually dispatched `Explicit independent benchmark QC` workflow can @@ -38,7 +38,7 @@ ERFA routine share the same standard; this checks implementation and integration not independent evidence that the standard itself is correct. With a dataset, 375 additional chart cases cover 125 dates in 1851–2099 across -tropical, Lahiri and Fagan-Bradley. Eleven major/derived bodies supply longitude, +tropical, Lahiri and Fagan-Bradley. Thirteen major/derived bodies supply longitude, speed and declination; limits are one arcsecond and one arcsecond/day. The runner uses captured effective library TT for matched offset checks, and the physical TT corresponding to each UTC input for end-to-end body checks. The latter retain @@ -55,6 +55,22 @@ plus requested/actual house-system identity. These are matched-time diagnostics; they do not establish the library's UTC-to-UT1 model accuracy. Angle speeds and declinations are not benchmarked. -Reports explicitly set `fullChartCoverage: false`: polar library house QC, -nodes, Ceres, Chiron and True Sky are still outside this client. Unsupported coverage and -calculation errors are failures, never silently skipped successful evidence. +The North and South Node checks explicitly target the benchmark's osculating +geometric orbital-plane model, with symbolic South declination (same as North). +This client does not claim that the library supports switching to mean nodes +or geometric South declination. The benchmark's effective node model is +validated before any numerical comparison. + +An additional 48 polar chart cases use March 20, 2026 at 00:00, 06:00, 12:00 +and 18:00 UTC, latitudes -85, -70, 70 and 85 degrees, longitude zero, all three +zodiacs and Placidus with explicit reference Porphyry fallback. Total coverage +with a dataset is 831 cases, including 423 chart inputs. Native and Wasm are +attempted independently even if one rejects a chart. Reports preserve every +input, target outcome and independent reference geometry, including rejected +charts. Missing outcomes cannot count as passing evidence. + +Reports explicitly set `fullChartCoverage: false`: mean-node and geometric +South-node library QC, Ceres, Chiron and True Sky remain outside this client. +Unsupported coverage and calculation errors fail the gate. See the +[current findings](novas-qc-findings.md) for the polar limitation retained by +this sweep. diff --git a/tools/verification/novas-qc.mjs b/tools/verification/novas-qc.mjs index 3bb8a6a..ba177f1 100644 --- a/tools/verification/novas-qc.mjs +++ b/tools/verification/novas-qc.mjs @@ -32,6 +32,9 @@ const checked=async(path,input)=>{const result=await get(path,input);assert(JSON assert(Array.isArray(result.geometry.cusps)&&result.geometry.cusps.length===12&&result.geometry.cusps.every(Number.isFinite),'Reference cusp coverage mismatch'); assert(result.geometry.angles&&Object.keys(result.geometry.angles).sort().join(',')==='ascendant,descendant,imumCoeli,midheaven'&&Object.values(result.geometry.angles).every(Number.isFinite),'Reference angle coverage mismatch'); } + if(input.nodeMode){ + assert(result.nodeConvention?.mode===input.nodeMode&&result.nodeConvention.southDeclination===input.southNode&&result.nodeConvention.model==='DE440s-geocentric-inertial-orbital-plane'&&result.nodeConvention.corrections==='geometric; no light-time, aberration or deflection','Reference node convention mismatch'); + } return result;}; const cases=[]; const add=(utc,zodiac='lahiri',chart=false,geometry={})=>cases.push({id:`case-${cases.length}`,utc,zodiac,chart,latitude:0,longitude:0,houseSystem:'whole-sign',...geometry}); @@ -43,6 +46,7 @@ for(let index=0;index<128;index++){ add(new Date(Date.UTC(1801,0,1)+(seed/2**32)*(Date.UTC(2199,0,1)-Date.UTC(1801,0,1))).toISOString()); } if(dataset)for(let index=0;index<125;index++)for(const zodiac of ['tropical','lahiri','fagan-bradley'])add(new Date(Date.UTC(1851+index*2,index%12,15,12)).toISOString(),zodiac,true,{latitude:[-65,-45,0,45,65][index%5],longitude:-175+(index%24)*15,houseSystem:['whole-sign','equal','placidus'][index%3]}); +if(dataset)for(const latitude of [-85,-70,70,85])for(const hour of [0,6,12,18])for(const zodiac of ['tropical','lahiri','fagan-bradley'])add(new Date(Date.UTC(2026,2,20,hour)).toISOString(),zodiac,true,{latitude,longitude:0,houseSystem:'placidus'}); const datasetBytes=dataset?readFileSync(resolve(dataset)):null; const captured=spawnSync('cargo',['run','--quiet','--locked','--target-dir',resolve(root,'target/novas-qc'),'--features','generation','--example','novas_qc_capture','--',...(dataset?[resolve(dataset)]:[])],{cwd:root,input:JSON.stringify(cases),encoding:'utf8',maxBuffer:32*1024*1024}); assert(captured.status===0,captured.stderr||'Native capture failed'); @@ -61,13 +65,13 @@ if(useWasm){ const module=await import(pathToFileURL(resolve(root,'examples/configurable-chart/engine/chart.js'))); await module.default({module_or_path:readFileSync(resolve(root,'examples/configurable-chart/engine/chart_bg.wasm'))});wasmCalculate=module.calculate; } -const rows=[];const errors=[]; +const rows=[];const errors=[];const chartOutcomes=[];const referenceGeometry=[]; const row=(id,path,actual,expected,tolerance,angular=false)=>{ assert(Number.isFinite(actual)&&Number.isFinite(expected),`Nonfinite/missing ${id} ${path}`); const delta=angular?((actual-expected+540)%360+360)%360-180:actual-expected; rows.push({id,path,actual,expected,delta,tolerance,pass:Math.abs(delta)<=tolerance}); }; -const supported=['SUN','MOON','MERCURY','VENUS','MARS','JUPITER','SATURN','URANUS','NEPTUNE','PLUTO','EARTH']; +const supported=['SUN','MOON','MERCURY','VENUS','MARS','JUPITER','SATURN','URANUS','NEPTUNE','PLUTO','EARTH','NORTH_NODE','SOUTH_NODE']; for(let index=0;index{ assert(chart.configuration.reference===input.zodiac,'Chart zodiac mismatch'); assert(new Set(chart.bodies.map(body=>body.name)).size===chart.bodies.length,'Duplicate chart body'); - const physical=await checked('/v1/calculate',{jdTT:value.physicalJdTT,zodiac:input.zodiac,bodies:supported}); + const physical=await checked('/v1/calculate',{jdTT:value.physicalJdTT,zodiac:input.zodiac,bodies:supported,nodeMode:'osculating',southNode:'symbolic'}); for(const name of supported){ const body=chart.bodies.find(body=>body.name===name);assert(body,`Missing ${name}`); for(const field of ['longitude','declination','speed'])row(input.id,`${target}.endToEnd.${name}.${field}`,body[field],physical.bodies[name][field],1/3600,field==='longitude'); } - const geometry=await checked('/v1/calculate',{jdTT:value.effectiveJdTT,jdUT1:value.effectiveJdUT1,zodiac:input.zodiac,bodies:['SUN'],location:{latitude:input.latitude,longitude:input.longitude},houseSystem:input.houseSystem,polarHouses:'porphyry'}); assert(chart.requested_house_system===input.houseSystem,'Requested house system mismatch'); if(chart.house_system!==geometry.geometry.actualSystem)errors.push({id:input.id,target,error:'Actual house system mismatch',actual:chart.house_system,expected:geometry.geometry.actualSystem}); assert(Array.isArray(chart.cusps)&&chart.cusps.length===12,'House cusp coverage mismatch'); @@ -99,17 +102,27 @@ for(let index=0;indexvalue.target===target).length===cases.filter(value=>value.chart).length,`Incomplete ${target} chart attempts`); assert(sourceHash()===snapshotHash,'Source changed during QC'); -const report={execution:{platform:process.platform,arch:process.arch,node:process.version,rust:execFileSync('rustc',['--version'],{cwd:root,encoding:'utf8'}).trim()},sourceHash:snapshotHash,nativeBinaryHash,wasmArtifacts,scope:dataset?'major-planets-houses-and-reported-offsets':'reported-lahiri-and-original-nutation',fullChartCoverage:false, - omitted:['polar-library-house-QC','nodes','Ceres','Chiron','True Sky'],cases:cases.length,chartCases:cases.filter(c=>c.chart).length,targets:useWasm?['native','wasm']:['native'], +const report={execution:{platform:process.platform,arch:process.arch,node:process.version,rust:execFileSync('rustc',['--version'],{cwd:root,encoding:'utf8'}).trim()},sourceHash:snapshotHash,nativeBinaryHash,wasmArtifacts,scope:dataset?'major-planets-nodes-houses-and-reported-offsets':'reported-lahiri-and-original-nutation',fullChartCoverage:false, + omitted:['mean-node-library-QC','geometric-south-node-library-QC','Ceres','Chiron','True Sky'],cases:cases.length,chartCases:cases.filter(c=>c.chart).length,targets:useWasm?['native','wasm']:['native'], + inputs:cases,chartOutcomes,referenceGeometry,nodeReference:{mode:'osculating',southDeclination:'symbolic'}, pass:errors.length===0&&rows.every(row=>row.pass),rows,errors,benchmark:health.provenance, sourceRevision:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(), workingTreeDirty:execFileSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}).trim().length>0, From 0d40fc470e3cb9391ce84c9942a34ce8274b42ab Mon Sep 17 00:00:00 2001 From: Jack Steam Date: Fri, 13 Nov 2026 14:42:25 -0600 Subject: [PATCH 4/4] Extend independent QC to Ceres and Chiron --- tools/verification/novas-lock.json | 10 ++++++---- tools/verification/novas-qc-findings.md | 7 ++++++- tools/verification/novas-qc.md | 8 ++++++-- tools/verification/novas-qc.mjs | 4 ++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/tools/verification/novas-lock.json b/tools/verification/novas-lock.json index 1177ef9..ca135c0 100644 --- a/tools/verification/novas-lock.json +++ b/tools/verification/novas-lock.json @@ -5,9 +5,11 @@ "version": "1.8.0", "calceph": "5.0.1", "erfa": "2.0.1", - "serviceHash": "88679d674780b4c04fb0188b100447b6d333f7a95a83d89f91d93a9b0814246e", - "dependencyHash": "68c569532019a16d4fe01721a942b75eb546c58b4bbdd964d38fdc6e7668c694", - "dataHash": "c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2" + "serviceHash": "358a650b2db4b814ed1304f2f52ccb25476dd82b173572a0501d08a8036a5476", + "dependencyHash": "63fba9330cee63c15b5d7adf1870d2c4a1e948cd509bf93507f24cfc9c2faff0", + "dataHash": "c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2", + "ceresDataHash": "7af67832bbd60a3a0b6f3643a7eb7de25964cd6f4b4f1005ea637da1564993fc", + "chironDataHash": "71145196b709f04f8461c386dbac472ec581e7a237a0744ff26a7fe48f2c1666" }, - "revision": "818ccf73a21098a45bd3b0f3f4956bf9aca6c66f" + "revision": "5beb4cb3734cb13fad2eceaa1065de32798dfed5" } diff --git a/tools/verification/novas-qc-findings.md b/tools/verification/novas-qc-findings.md index 6dfbbab..d20e39e 100644 --- a/tools/verification/novas-qc-findings.md +++ b/tools/verification/novas-qc-findings.md @@ -9,7 +9,7 @@ Reference: independent SuperNOVAS service revision in `novas-lock.json`. Reproduce with the native/Wasm command in [the QC guide](novas-qc.md). The expanded sweep includes 831 cases, of which 423 request charts. It -completes 48,525 numerical checks without numerical failures. This includes +completes 53,457 numerical checks without numerical failures. This includes 4,932 node checks, whose maximum absolute residual is 0.391780 arcseconds (or arcseconds/day for rates), within the unchanged one-arcsecond limits. @@ -35,3 +35,8 @@ The library already documents rejection of overlapping polar house sets. These results expose that coverage limitation; they do not by themselves justify changing its convention. Any library house change requires a separate implementation and review. The benchmark gate remains strict in the meantime. + +The expanded body list also includes Ceres and Chiron. All 4,932 completed +minor-body checks pass at the original angular/rate thresholds. Polar chart +rejections still prevent complete chart coverage; no values are fabricated +for rejected charts. diff --git a/tools/verification/novas-qc.md b/tools/verification/novas-qc.md index b159a13..9910793 100644 --- a/tools/verification/novas-qc.md +++ b/tools/verification/novas-qc.md @@ -38,7 +38,7 @@ ERFA routine share the same standard; this checks implementation and integration not independent evidence that the standard itself is correct. With a dataset, 375 additional chart cases cover 125 dates in 1851–2099 across -tropical, Lahiri and Fagan-Bradley. Thirteen major/derived bodies supply longitude, +tropical, Lahiri and Fagan-Bradley. Fifteen major/derived bodies supply longitude, speed and declination; limits are one arcsecond and one arcsecond/day. The runner uses captured effective library TT for matched offset checks, and the physical TT corresponding to each UTC input for end-to-end body checks. The latter retain @@ -70,7 +70,11 @@ input, target outcome and independent reference geometry, including rejected charts. Missing outcomes cannot count as passing evidence. Reports explicitly set `fullChartCoverage: false`: mean-node and geometric -South-node library QC, Ceres, Chiron and True Sky remain outside this client. +South-node library QC and True Sky remain outside this client. Unsupported coverage and calculation errors fail the gate. See the [current findings](novas-qc-findings.md) for the polar limitation retained by this sweep. + +Ceres and Chiron use the pinned Horizons kernels identified by separate hashes +in the lock and service provenance. Their longitude, declination and rates +are included in both native and Wasm end-to-end checks. diff --git a/tools/verification/novas-qc.mjs b/tools/verification/novas-qc.mjs index ba177f1..45be5dd 100644 --- a/tools/verification/novas-qc.mjs +++ b/tools/verification/novas-qc.mjs @@ -71,7 +71,7 @@ const row=(id,path,actual,expected,tolerance,angular=false)=>{ const delta=angular?((actual-expected+540)%360+360)%360-180:actual-expected; rows.push({id,path,actual,expected,delta,tolerance,pass:Math.abs(delta)<=tolerance}); }; -const supported=['SUN','MOON','MERCURY','VENUS','MARS','JUPITER','SATURN','URANUS','NEPTUNE','PLUTO','EARTH','NORTH_NODE','SOUTH_NODE']; +const supported=['SUN','MOON','MERCURY','VENUS','MARS','JUPITER','SATURN','URANUS','NEPTUNE','PLUTO','EARTH','NORTH_NODE','SOUTH_NODE','CERES','CHIRON']; for(let index=0;indexvalue.target===target).length===cases.filter(value=>value.chart).length,`Incomplete ${target} chart attempts`); assert(sourceHash()===snapshotHash,'Source changed during QC'); const report={execution:{platform:process.platform,arch:process.arch,node:process.version,rust:execFileSync('rustc',['--version'],{cwd:root,encoding:'utf8'}).trim()},sourceHash:snapshotHash,nativeBinaryHash,wasmArtifacts,scope:dataset?'major-planets-nodes-houses-and-reported-offsets':'reported-lahiri-and-original-nutation',fullChartCoverage:false, - omitted:['mean-node-library-QC','geometric-south-node-library-QC','Ceres','Chiron','True Sky'],cases:cases.length,chartCases:cases.filter(c=>c.chart).length,targets:useWasm?['native','wasm']:['native'], + omitted:['mean-node-library-QC','geometric-south-node-library-QC','True Sky'],cases:cases.length,chartCases:cases.filter(c=>c.chart).length,targets:useWasm?['native','wasm']:['native'], inputs:cases,chartOutcomes,referenceGeometry,nodeReference:{mode:'osculating',southDeclination:'symbolic'}, pass:errors.length===0&&rows.every(row=>row.pass),rows,errors,benchmark:health.provenance, sourceRevision:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(),