diff --git a/docs/api/comparison-archive.md b/docs/api/comparison-archive.md new file mode 100644 index 00000000..f5c114fa --- /dev/null +++ b/docs/api/comparison-archive.md @@ -0,0 +1,201 @@ +# Saved native comparison evidence, schema 1 + +`desktop/comparison_archive.rs` adds immutable recipe and derived-result artifacts +on top of the native analysis contracts in +[recording-comparison.md](recording-comparison.md). It uses the existing M2 run-set +owner and its publication/decoding helpers. It stores intent, identities, coverage +and reductions; numerical fields remain in the existing SQLite recording owner. +No worker, model runtime, numerical store or integrator checkpoint is added. + +This continuation is admitted by the parent/user for the new comparison archive +module, focused tests and this API document, plus desktop-only serialization in +`recording/comparison.rs`. The original PR86 commit remains intact. Shared module +exports, Cargo, desktop composition, the plan and all merges remain lead-owned. +The application module expects `super::native_comparison` to name the numerical +analysis module and `super::run_sets` to name the M2 source owner. Focused tests +path-load those modules while shared integration is pending. + +## Identity and source custody + +`SavedRecipe::capture` takes a complete immutable `RunSetRevision`, two explicit +member IDs, ordered source ordinals and the full native `Recipe`. Each chosen +member must resolve through M2 `open_member` as a ready, terminal, pinned native +archive. Its `RunAttempt.finalized` retains the exact whole-file SHA256, record +count, terminal outcome and accepted generation. The recipe embeds the complete +run-set revision and its M2 canonical-content SHA256, plus every selected +`SelectionIdentity` (run/source/ordinal/generation and exact publication SHA256). +The canonical run-set digest identifies the revision's content; it is not a hash +of an external manifest file. Embedding the revision makes cold recipe inspection +independent of an external manifest or a mutable latest alias. + +The source reader validates native profile/spec provenance, exact geometry, +recording limits, terminal state, publication schedule and field schemas through +M2. No source provenance or controls Debug string becomes runnable input. Source +artifact hashes and selected-publication hashes serve different purposes: a +changed terminal receipt invalidates the whole artifact pin even if the chosen +field bytes remain unchanged. Archive ownership, protected paths and immutable +terminal files remain caller obligations throughout capture and verification. + +`SavedRecipe::open` checks the artifact and recipe structure/digests without +opening archives. It can cold-open on a machine with no model runtime. `compute` +resolves the exact pinned members, reselects the explicit ordinals, checks every +publication identity and calls the existing native `align`. No latest file, +live state, nearest ordinal, field substitute or implicit re-execution is used. +A missing/corrupt/changed source returns its explicit source failure. + +## Derived result verification + +A result embeds its exact saved recipe and complete native `Coverage`, including +analysis version, full recipe, both identities for every pair, unmatched identities +on both sides, available finite metrics or typed unavailable reasons, units, +gauge report and common finite color bounds. Pair identities and unmatched +identities must partition the selected sources exactly once. The codec checks +that versions, recipes, units, gauge policy, finite/nonnegative metrics and bounds +are structurally consistent. Nullable relative L2 and gauge keys must be present; +JSON null retains their explicit absence. Unknown keys and closed enum variants +under schema 1 are invalid codec input. + +`UnverifiedComparison::open` deliberately returns an unverified type. Its +`recorded_coverage` accessor exposes stored derived evidence, not verified facts. +A valid envelope hash proves internal content consistency, not accurate metrics, +authenticated provenance, accepted physics or source availability. + +`verify(root)` reopens exact archive pins and recomputes the entire comparison, +then compares canonical serialized coverage digests. Only successful recomputation +returns `VerifiedComparison`; plausible finite metrics with a self-consistently +rehashable envelope still fail if they differ from their sources. Verified means +verified against the exact recorded source bytes in that operation. It does not +qualify a solver, model, physics plan, GUI workflow or later mutable file state. +The verified type exposes immutable recipe/coverage accessors, content digest and +`save_new`. Reopening its saved artifact starts unverified again. + +Native analysis semantics remain owned by analysis version 1: physical-time/stage +alignment, native geometry/association/units, raw f32/f64 reduction, explicit +pressure gauge diagnostics and no cross-run particle pairing or remapping. The +M2 source profile requires canonical native carrier publications; an offset +analysis policy does not bypass that source validation or promote diagnostics. +Warmup, attempted/fallback counts, timing scopes and teacher/independent model +attempt evidence remain unavailable when not present as typed source facts. + +## Codec and publication + +Both artifact kinds use a strict JSON envelope: + +| Field | Meaning | +| --- | --- | +| `schema_version` | Comparison artifact syntax version, currently 1 | +| `kind` | `recipe` or `result` | +| `sha256` | SHA256 of the payload's canonical serde JSON serialization | +| `payload` | Saved recipe, or saved recipe plus full coverage | + +Schema, analysis and embedded run-set versions are independent. Unknown outer +schema versions return unsupported before full decoding. Within schema 1, unknown +analysis versions in the supported integer domain return unsupported; unknown +required variants/keys and malformed, duplicate, out-of-domain or truncated +values are invalid codec input. Shape changes require a schema change. The +original bytes are decoded through strict types; no generic JSON map erases +duplicate fields before validation. + +Publication reuses M2 `publish_new`: stage a fresh sibling file, sync it, hard-link +without replacing the destination, sync the directory, remove staging and sync +again. It requires caller-owned protected local paths and filesystem support for +hard links and directory sync. Interrupted staging is not authoritative and is +not silently replaced/deleted as recovery. Once linked, later failures retain +`PublicationUncertain`; callers reopen to reconcile. Existing recipe/result files +are never overwritten. Each recomputation is explicitly published to a new path. + +Artifacts are bounded to 1 MiB. Each source selects 1–128 strictly increasing +ordinals. Capture/compute incrementally enforce a 64 MiB aggregate selected raw +field-byte budget before retaining another selected publication. This is a +selected-payload/work budget, not a process RSS, allocation or I/O guarantee: +recording verification may read the entire bounded archive, a read can temporarily +hold one additional bounded record, metadata takes memory, and numerical analysis +allocates promoted f64 arrays. M2's recorded/caller recording limits still apply. + +## Evidence and remaining integration + +The contract tests use actual Jacobi and SGS simulations with nonzero smoke +forcing, `bound_native_run`, `StepRecorder` and pinned `RecordingReader` sources. +One producer process writes archives/recipe/result and exits; a separate process +cold-opens and recomputes the result. Tests cover every native field unit, exact +zero-reference absence, pressure gauge labeling, unavailable geometry and +unmatched selections, no-overwrite, moved/missing/corrupt/changed source files, +changed exact selection identities, plausible rehashed metric tampering, +structural/gauge/unit/coverage damage, unknown versions/variants/keys, duplicate +keys, truncation, digest damage, required nullable facts and artifact/selection +bounds. The independent numerical oracle remains in the native comparison tests; +this codec does not replace that evidence with round-trip agreement. + +Observed on Linux CPU, Rust 1.92.0 (2026-10-15): 12 archive contract tests and 15 +native comparison tests pass with the desktop feature; the 15 core comparison +tests also pass without default features. Focused desktop Clippy with warnings +denied and Rust formatting pass. No new dependencies were added; serde and +serde_json remain gated by the existing desktop feature. Core-only comparison +continues to compile without those optional codecs. + +```sh +cargo test --locked --features desktop --test comparison_archive_contract --test recording_comparison_contract +cargo test --locked --no-default-features --test recording_comparison_contract +cargo clippy --locked --features desktop --test comparison_archive_contract --test recording_comparison_contract -- -D warnings +``` + +The clean combined stack includes the admitted M2 source directly; no untracked +helper copy is needed. Root module wiring remains lead-owned. The actual external +model/runtime workflow remains separate acceptance evidence. + +## Cold comparison page + +`desktop/comparison.rs` owns `SavedComparisonPage`. The parent supplies navigation +and calls `show(&mut egui::Ui)` within its panel; the page adds no worker or field +renderer. `set_location(root, manifest_relative_path)` binds a protected local +dataset namespace. Manifest, saved artifact and export paths are explicit relative +paths. The page shows saved baseline/differences, member intent/terminal facts, +missing/incomplete/unpinned/invalid states and accepted-but-unrecorded tails. +Availability is refreshed on explicit load, compute, verify or failed operations; +sources are not monitored in the background. + +Members, inclusive ordinal ranges (at most 128 publications per side), six field +roles, physical-time tolerance and pressure policy are explicit controls. Source +or scientific edits invalidate previous presentation. Decoded results display +Unverified until source verification succeeds; actual source times/stages are not +claimed from stored identities alone. Failed operations clear previously verified +metrics and refresh member states. Recipe/result exports reuse no-replace atomic +publication, including duplicate-path refusal. + +`selected_pair()` returns only the current verified pair, its exact saved recipe +and root. A linked-field consumer must also check the pair's typed outcome and +resolve the recipe's pinned source identities. This is the integration seam for +the existing paired renderer; it supplies no synthetic Scene/Frame, model output +or alternate display reconstruction. Root registration and linked rendering are +still pending lead allocation. Native-v1 timing/provider-attempt/warmup/fallback/RHS +facts remain explicitly unavailable. + +`load_selected_fields()` is an explicit synchronous I/O/materialization operation +for that renderer consumer, not a per-frame getter. Empty or Unverified pages +return no data before source I/O. It reopens both exact archive pins, checks the +selected publication identities, calls `compared_values`, and checks the returned +summary against the verified pair. Source changes clear old verification and +refresh availability. A scientifically unavailable pair remains verified analysis +with a typed unavailable result and no fabricated arrays. Rendering consumers +must clear previous fields on every failed/unavailable materialization. Returned +geometry/sampling preserve native face dimensions and offsets; no cell averaging +or remapping occurs. Promoted f64 arrays are transient and not an RSS guarantee. + +Seven real-producer headless egui interaction tests and focused Clippy/formatting +pass; independent GPT-6.1 Sol High review approved the exact source. Actual Linux +X11 interactions exercised this same page in a temporary eframe verification +harness: cold result Unverified→Verify, pressure interval/time/unit/gauge labels, +export, duplicate export without replacement and missing-source invalidation. +The harness uses authenticated Xvfb and Mesa llvmpipe software GL. A fresh process +recomputed the exported result after the window exited. This is narrow cold-page +evidence, not final desktop navigation, linked views, live worker lifecycle, model +runtime, hardware GPU or full M2–M3 acceptance. Exact source/environment identities +and remaining dependencies are in the [stack report](../plans/configurable-solver-components/reports/m23-persistence-comparison.md). + +After the presentation-data addition, the fresh combined desktop run passes +17 persistence, 12 saved-comparison, 18 numerical-comparison and 9 UI contracts. +Independent review also passes the 18 numerical contracts without default +features, focused Clippy in both modes and formatting. Actual X11 materialization +on the final page returns native cell/face values only after verification and +clears stale presentation after source disappearance. The final page and core +hashes are recorded in the stack report. diff --git a/docs/api/recording-comparison.md b/docs/api/recording-comparison.md new file mode 100644 index 00000000..76ca1cc1 --- /dev/null +++ b/docs/api/recording-comparison.md @@ -0,0 +1,237 @@ +# Native recording comparison, analysis v1 + +`src/recording/comparison.rs` is an additive read-only analysis module. It is +compiled through the isolated `recording_comparison_contract` integration test +until the integration owner admits its `recording::comparison` export. It owns +neither accepted publication, recording storage, runtime execution nor a worker. +The canonical implementation operation is M3 of +`docs/plans/configurable-solver-components/plan.md` at PR84 `f45cd91`, admitted +by the parent/user for these four new files. This does not accept A5's GUI or +model-runtime workflow claims. + +## Source selection and identity + +`Selection::read(reader, ordinal)` obtains one exact immutable owned publication +through the real `RecordingReader`, which verifies its stored hash and metadata. +`identity()` retains run ID, declared source revision, ordinal, generation and an +analysis-v1 SHA256 digest. `digest()` returns that digest; `run_digest()` binds +run metadata. The module-level `run_digest(reader.run())` also supports archives +with no selected publication. `run()` and `publication()` expose immutable +source facts. +Selections remain bounded by the reader's recording limits. A caller building +selection arrays owns the aggregate memory and count budget; alignment is +quadratic in those caller-selected counts and holds no additional field cache. + +The run digest is SHA256 of domain bytes `rheon-native-run-v1\0`, run ID as +little-endian u64, source revision/solver/configuration/provenance as +length-prefixed UTF-8, counts as three LE u64 and spacing/origin as six LE f64. +Lengths use LE u64. The selection digest is SHA256 of domain bytes +`rheon-native-selection-v1\0`, the 32-byte run digest, ordinal/generation as LE +u64, time as LE f64, optional dt, stage byte (constructor=0, accepted=1), controls +text, field count and every field in recorded order. Fields encode +name/units/association/stage/scalar type text, optional evaluation time, dimensions +(three LE u64), components, byte length and raw bytes. An optional value encodes +presence byte 0/1 followed by LE f64 only when present. All text is prefixed by +its UTF-8 byte length. Exact native f32/f64 bits are retained, including signed +zero. These are analysis identities, independent of SQLite page layout, archive +limits, lifecycle, record count and subsequent append/finish. They are not file +hashes, authentication or proof of declared provenance. + +Completed archive-file hashes and application run-set membership are separate +owners. The module does not silently select latest data, derive a completed +outcome from the last record, mutate archives or execute model code. + +## Supported fields and compatibility + +Only `rheon-carrier-full-box-v1` records with a recognized native pressure method +are supported. Unknown profiles return `UnsupportedProfile`; they are not +interpreted by guessing field names or parsing configuration/controls Debug +text. Native field schemas must match the existing carrier producer exactly: + +| Role | Native support | Units/stage | +| --- | --- | --- | +| Tracer | all cell centers, f32 | 1; constructor or accepted endpoint | +| Velocity X/Y/Z | each full native face lattice, f32 | m/s; constructor or accepted endpoint | +| Interval pressure | all cell centers, f64 | Pa; accepted projection interval, cell0 gauge | +| Velocity divergence | all native cells from three stored face arrays | 1/s; same endpoint | + +Compare requires identical counts and bit-identical spacing/origin, the same +publication stage, equal actual physical endpoint times within explicit finite +nonnegative absolute tolerance (seconds), and exact field units, association, +dimensions, scalar kind, components and stage. Pressure additionally requires +matching actual interval starts within tolerance and a valid stored interval +start/end/dt relation. Same ordinal or generation alone never aligns runs. +Native geometry and stage are authoritative; camera, quantization and display +pixels are absent from analysis. There is no spatial/time remapping, +interpolation, particle ID pairing, periodic-grid assumption or derived pressure +residual/Courant claim. + +`align(reference, candidate, recipe)` matches physical time/stage one-to-one. +Multiple candidates within tolerance or reuse of one candidate for two +reference selections return `AmbiguousAlignment`; there is no nearest-match or +input-order tie breaking. The result binds analysis version 1 and the complete +recipe, and reports paired +identities plus unmatched identities on both sides. A temporal pair with +incompatible geometry or an absent/invalid field remains a paired unavailable +outcome. For a malformed/missing pressure interval field, authoritative +publication time−dt is used only to retain the temporal pair; field validation +still returns `FieldSchema`/`MissingField`. Valid actual unequal intervals remain +unmatched. Thus temporal coverage and usable numerical support are distinguishable. +All source values are validated finite f32/f64 through the recording owner; +nonfinite intermediate arithmetic returns `ArithmeticRange`. + +## Reductions and gauge + +Errors use the declared convention candidate − reference. Metrics include +Euclidean L2, native-volume-weighted RMSE, Linf, support count and relative L2. Uniform Cartesian cell/face +sample volumes have equal weights, so weighted RMSE is +`sqrt(sum(error²)/support_count)`. The full face lattice uses equal sample +weights; no half-volume boundary quadrature is claimed. L2 is the Euclidean +sample norm, not its volume integral. Relative L2 is absent precisely when the +reference norm is zero; absolute errors remain available. Empty support is +unavailable; no hidden epsilon or zero-valued missing result is used. + +Divergence at (i,j,k) is the sum of native upper-minus-lower face velocities over +actual spacing on each axis. It uses stored f32 face values promoted to f64, +x-fast indexing, unequal counts and anisotropic spacing. This measures the +recorded velocity field; it does not infer residual from pressure or reproduce +numerical acceptance. + +`PressurePolicy::Canonical` requires each selected pressure's actual first cell +to equal zero. `RemoveMeanOffsetDiagnostic` subtracts mean(candidate − reference) +from the candidate before reduction and common color bounds. Its report retains +the removed offset in Pa, each original canonical-gauge status and a diagnostic +label. It does not repair or promote a source publication; canonical-gauge +failure remains visible. Direct pressure metrics require matching canonical +gauge. Common color bounds are the finite union of both compared arrays after +any declared transform; constant fields legitimately produce min=max. + +## Native presentation data + +`compared_values(reference, candidate, recipe)` synchronously materializes a +single immutable selected pair. It returns `ComparedValues` with owned promoted +f64 reference/candidate arrays, the exact `PairComparison` summary and identities, +native `NativeGeometry` (counts XYZ, spacing/origin in metres), and `NativeSampling`. +The sampling descriptor contains association, actual scalar lattice dimensions, +and sample offsets in cells. Cartesian XYZ and x-fast ordering are preserved. +No arrays or descriptors are serialized by this API. + +| Association | Sample dimensions | Offset in cells | +| --- | --- | --- | +| Cell center (tracer, pressure, divergence) | `[nx, ny, nz]` | `[0.5, 0.5, 0.5]` | +| Native X face | `[nx+1, ny, nz]` | `[0, 0.5, 0.5]` | +| Native Y face | `[nx, ny+1, nz]` | `[0.5, 0, 0.5]` | +| Native Z face | `[nx, ny, nz+1]` | `[0.5, 0.5, 0]` | + +The physical sample point is `origin_m + (sample_index + offset_cells)*spacing_m` +componentwise. Face arrays are never cell-centered, averaged, interpolated or +remapped. A renderer that supports only cell centers must report its limitation +for a face association; it cannot relabel faces as cells or fabricate a Frame or +Scene. The metadata enables an honest native sample projection, not a new +renderer or physical-operator contract. + +The function first calls the existing `compare`, then reuses the existing native +`values` extraction. For pressure it subtracts only the existing summary's exact +`GaugeReport.removed_offset_pa` from the candidate array. It does not recompute a +mean, reduction, divergence stencil or color scale through another algorithm. +Direct values preserve native f32-to-f64 promotion and raw f64 bits; divergence +continues to use the established stored-face calculation. Unavailable pairs +return `ComparedValuesError::Unavailable` with their original reason, and an +invalid recipe returns `ComparedValuesError::Recipe`. The persisted unavailable +variants, codec and analysis-version semantics are unchanged. + +This deliberately repeats bounded extraction after validation to avoid changing +the established numerical reduction. Source recording limits bound one selected +record; they are not a peak RSS limit. Two source records, promoted arrays and +transient divergence/reduction buffers can coexist. There is no new field cache, +persistent numerical store or worker, and callers own returned-array lifetimes. + +The cold page's `load_selected_fields` is an explicit synchronous consumer. It +returns no data and performs no source I/O without a current verified matched +pair. On invocation it reopens M2's exact terminal artifact pins, selects and +checks both publication identities, calls `compared_values` and checks its summary +against the currently verified pair. Missing/corrupt/changed sources clear old +verified presentation and refresh source availability snapshots. A valid but +unavailable field pair returns its typed unavailable reason while retaining its +verified analysis status. It never turns missing data into arrays or acceptance. +The existing `selected_pair` accessor remains cheap. A future linked renderer may +cache transient returned data by both exact pair identities and full recipe; +materialization is not a per-frame getter and does not itself register a view. + +Pointwise tests independently decode real Jacobi/SGS SQLite field bytes and check +all six role arrays. Direct scalar outputs are compared bit-for-bit after IEEE +promotion; the independent coordinate-dictionary/`fsum` oracle bounds the final +three-term divergence summation as described below. Separate analytical fixtures +check signed zero/subnormal f32 values, declared pressure-offset transforms and +constant divergence. The existing metric oracle is unchanged. Mutation fixtures +retain the original typed stage/time/geometry/schema/gauge/arithmetic failures. +The cold consumer tests verify no data/source access while Unverified, actual +native X-face sampling after verification, and invalidation on missing or changed +whole-file sources; unavailable constructor pressure remains verified-but-absent. + +## Evidence and limits + +The Rust contracts use real `Simulation` captures, `carrier_publication`, +`StepRecorder` and cold `RecordingReader` paths. Some tests mutate captured +fields as labeled analytical fixtures; they do not claim those synthetic +fields passed physics acceptance. Native face fixtures establish constant +analytical divergence on a non-cubic anisotropic grid. Tracer fixtures establish +L2/RMSE/Linf and exact zero-denominator behavior. An independent Python standard +library oracle reads raw SQLite blobs, unpacks native f32/f64 bits, uses +coordinate dictionaries for face derivatives and `math.fsum` for reductions, +and independently encodes the selected-publication fingerprint. Its command is +invoked by the Rust test, and oracle failure fails that test. This proves the +sampled native analysis contracts, not comprehensive external conformance. +A separate unmodified native source test records three Jacobi and two SGS accepted +intervals with common geometry, dt and nonzero smoke forcing, closes the producers, +and cold-compares two matched intervals plus one unmatched Jacobi tail. Its first +pair checks exact identities and all six role metrics with the Python oracle. + +For divergence only, the oracle accounts for the difference between Rust's two +sequential adds and Python's `math.fsum` of identical per-axis derivatives. The +per-cell absolute bound is `4*epsilon*sum(abs(derivative)) + 4*min_subnormal`. +It propagates the bound to L2 by a Euclidean norm, RMSE by division by sqrt(n), +and Linf by the maximum bound; candidate and reference bounds add for errors. +Relative L2 is checked against `[max(E−error_bound,0)/(N+reference_bound), +(E+error_bound)/(N−reference_bound)]`. If the reference norm interval reaches +zero, relative-oracle evidence is explicitly unavailable while absolute metrics +and exact hashes are still checked. All bounds and results must remain finite; +overflow cannot produce a vacuous pass. Other roles retain the fixed tight +reduction-rounding tolerance. This bounded oracle qualified the real native +pair's near-cancelled divergence without claiming bit-identical summation. + +Accepted-v1 records contain no typed attempted-interval count, provider fallback +outcome, warmup receipt, timing sample/scope, RHS/operator or diagnostic-attempt +identity. Warmup/fallback/cost/first-rejection/attempted-horizon comparisons and +host true residual are therefore unavailable in this module. Opaque Debug +provenance is not parsed. Rejected diagnostic records cannot be read as accepted +publications here; supporting them requires the recording owner's separate +versioned typed path. Offset-aligned pressure metrics are explicitly diagnostic +analysis of selected accepted-v1 source bytes, never diagnostic-step acceptance. +No speed, accuracy, GUI, CPU model runtime or completed run-set workflow claim is +made by these tests. + +Verification commands (Rust 1.92.0): + +```sh +cargo test --locked --no-default-features --test recording_comparison_contract +cargo test --locked --test recording_comparison_contract +cargo clippy --locked --no-default-features --test recording_comparison_contract -- -D warnings +rustfmt --check --edition 2024 src/recording/comparison.rs tests/recording_comparison_contract.rs +``` + +Observed verification on Linux CPU with Rust 1.92.0 (2026-10-15): the focused +comparison contract passed 15 tests in both no-default and default feature +configurations; the Python oracle passed tracer, interval pressure, divergence +and all three native velocity roles. Existing no-default recording contracts +passed 15 tests and carrier contracts passed 4 tests. Focused Clippy with warnings +denied, Rust formatting and Python compilation passed. These observations cover +this isolated module and the stated storage regressions; shared export wiring, +run-set/desktop integration and native interaction remain integration-owner work. + +Presentation-seam verification on Linux CPU, Rust 1.92.0 (2026-10-15): 18 native +comparison tests pass with no default features and with desktop enabled; 9 cold +page interaction/consumer tests pass with desktop enabled. Focused Clippy with +warnings denied and formatting pass. These tests establish native data custody +and the explicit materialization consumer, not linked rendering, shared desktop +registration or model/runtime acceptance. diff --git a/docs/api/saved-run-sets.md b/docs/api/saved-run-sets.md new file mode 100644 index 00000000..c62dd5bc --- /dev/null +++ b/docs/api/saved-run-sets.md @@ -0,0 +1,106 @@ +# Saved native specifications and run sets, version 1 + +`src/bin/desktop/run_sets.rs` owns immutable application intent and manifests over +existing [numerical SQLite recordings](recording.md). In this draft the module is +compiled through `tests/saved_run_set_contract.rs`; it is not yet registered in +the desktop. Integration and all merges belong to the configurable-solver lead. + +The admitted first configuration is constructor-zero native sealed-box carrier +with Jacobi PCG v1 or SGS PCG v1, constant requested dt, bounded requested interval +count and an optional initial contiguous SmokeSource schedule. It preserves grid +counts, spacing and origin, density, every existing pressure/full-step tolerance, +simulation memory limit and recording budgets. Initial velocity/tracer are zero. +No model, viscosity, obstacles, external initial assets, emitter, adaptive stepping, +restart, or provider fallback is implied. Accepted provider recording and diagnostic +attempt storage need the lead's actual M1 contracts before integration. + +`FrozenSpec` validates and content-identifies these controls. The composer creates +the existing Simulation with `spec.create_simulation()` and uses the same frozen +spec's `requested_dt_s` and `source_at(interval)` for execution. `bound_native_run` +checks actual constructor geometry/configuration/method and puts a versioned +full-spec SHA256 declaration in existing RecordingRun provenance. Source revision +and build strings remain caller-declared provenance. This binds intent; it does +not authenticate a producer or independently prove its source schedule was applied. +Recorded native fields and actual intervals remain the numerical observations. + +An existing native worker can instead use `FrozenSpec::capture_constructor` to +freeze the actual Simulation's typed geometry, configuration and implementation +before its first step. The caller supplies its explicit future dt, step count, +source schedule and recording budget. A progressed or reset simulation is rejected; +the helper neither owns the worker loop nor interprets Debug text or accepted +state as a checkpoint. + +`RunSetRevision` embeds exact frozen member specs, baseline, an optional parent +content identity and an ordered exact list of intentional differences. Editing +membership or intent creates a fresh revision; there is no mutable latest alias. +Retry uses a fresh attempt ID and archive path. The manifest contains no numerical +arrays, archive index/catalog, runtime handle or model-loading operation. The +existing recording owner remains authoritative for all fields and lifecycle data. + +## Persistence and reopening + +JSON uses explicit schema version 1, fixed typed fields, bounded inputs and exact +round-trip f64 values through the existing pinned serde_json float-roundtrip +feature. Spec and run-set digests hash their typed serialized payloads; these are +content identities, not signatures. Unknown versions return Unsupported; unknown +keys, variants, duplicates or malformed JSON fail decoding. Changed content with an +old digest returns Integrity. Files are limited to 1 MiB and sets to 64 members. +No migration, reinterpretation of saved Debug text, automatic recovery overwrite, +retention deletion or execution occurs on open. + +`save_new` stages a complete bounded manifest in a fresh sibling file, syncs it, +publishes with a no-replace hard link, syncs the directory, removes only its own +staged name, then syncs the directory again. Existing destinations are never +replaced, including when publishers race. This requires caller-owned protected +local directories with hard-link and directory-sync support; Linux is the tested +environment. A staging/interruption failure may leave a non-authoritative temporary +file. Once the destination link exists, a later failure is PublicationUncertain: +inspect the exact destination, preserve it and use a fresh path for a new revision. +No retry silently deletes data or guesses that publication failed. No filesystem +crash/power-loss test or other-platform durability claim has been made. + +`RunAttempt::pin_closed` is called after the writer/producer is closed. It checks +identity, geometry, method, typed full-intent declaration, actual recorded budgets, +integrity, every native carrier publication, actual dt and the requested horizon. +Completed runs cover all requested intervals and include constructor zero. Failed +or cancelled runs can retain a shorter prefix, an explicit unrecorded accepted +tail within the saved horizon, or zero publications. The final pin stores the +whole-file SHA256, count and authoritative terminal/accepted-generation facts. + +Cold `open_member` returns NotRun, Missing, Incomplete, Unpinned, Unsupported, +Invalid or Ready. Active archives remain incomplete even when their last record +is valid. An unpinned terminal archive is not silently promoted Ready. A pinned +archive must match its complete artifact identity, captured terminal facts and +native schema before a reader is returned. Ready means the reader is usable; +failed/cancelled terminal outcomes retain those meanings. Every selection uses an +explicit ordinal on that member's reader; no live or newer publication substitutes +for missing data. Unknown fields stay absent through RecordingReader. + +Paths are bounded canonical relative paths; absolute/parent/backslash/drive paths +and symlink components are refused. Caller protection must exclude concurrent path +replacement throughout validation and use, matching RecordingReader's local-path +contract. Opening inherits that owner's SQLite hot-journal recovery semantics; it +is not an OS-enforced read-only open. Archives are inspection records, not restart +checkpoints. Finalized archives are kept immutable after producer closure. + +## Verification boundary + +The contract test drives both real native implementations and the existing +StepRecorder, checks exact f32/f64 field bytes/times, and runs a producer OS process +to successful exit before starting a separate cold reader OS process. That reader +opens only manifests/RecordingReader and independently checks selected native bytes +against SQLite BLOBs. Tests also exercise strict bounds/version/integrity failures, +immutable revisions, corrupt/missing/incomplete/unpinned states, changed source +intent, historic opaque Debug provenance, native schema failures, budget/horizon +mismatches, unrecorded tails, path rejection and racing manifest publication. + +These establish the bounded native automated contracts. They do not establish +accepted-provider provenance, generic diagnostic storage, native-window operation, +model runtime/lifecycle behavior, crash durability, physical accuracy or M2–M3 +acceptance as a whole. + +The app-private bounded publication/versioned-read/path/digest helpers are reused +by the saved-comparison document owner. Each caller supplies its own expected +schema version and practical byte budget; these are not executable configuration +or numerical archive versions. The native spec/run-set document budget remains +1 MiB. Zero and maximum-u64 budgets are invalid before oversize-read arithmetic. diff --git a/docs/plans/configurable-solver-components/reports/m2-native-contracts.md b/docs/plans/configurable-solver-components/reports/m2-native-contracts.md new file mode 100644 index 00000000..0b08d93a --- /dev/null +++ b/docs/plans/configurable-solver-components/reports/m2-native-contracts.md @@ -0,0 +1,162 @@ +# M2 native saved-intent contract handoff + +Scope authority: user explicitly admitted M2–M3 implementation against +[PR84 plan f45cd91](https://github.com/MrScripty/Rheon/blob/f45cd91dab845c975fa6ab6952af02d5ba04d14b/docs/plans/configurable-solver-components/plan.md) +and its M0 reconciliation. This supersedes that proposal's earlier documentation-only +authorization for this delegated scope; it does not waive its numerical boundaries. +The integration lead owns M1, all shared composition and ALL merges. + +## Exact ownership and disposition + +Branch `feature/saved-runsets-m2-20261015`, worktree `/workspace/rheon-m2`, starts at +`0fbadbe733b5b521e192dc8a2aa980d6d69a1e01`. Only new app-owned +`src/bin/desktop/run_sets.rs`, `tests/saved_run_set_contract.rs`, +`docs/api/saved-run-sets.md` and this report are in the write set. No existing +source, exports, schema, Cargo/lock, workflow or other-agent work was changed. +The contracts are compiled through test path inclusion pending serial desktop +registration. They are draft implementation, not advertised production capability. + +Retain this branch/worktree protected by its local and published branch while +review and dependent integration remain open. Owner: M2–M3 delegate; integration +and terminal retirement: lead. Keep it until all dependent branches no longer +need it and the lead records accepted/rejected/superseded disposition. No cleanup, +force removal, shared-history rewrite or independent merge is performed here. + +Coordination is public on PR84 comments +[6198401466](https://github.com/MrScripty/Rheon/pull/84#issuecomment-6198401466) +and [6198560575](https://github.com/MrScripty/Rheon/pull/84#issuecomment-6198560575). +The selected environment is a fresh snapshot with no actual repository/ancestor +AGENTS or `.agents/skills`. The local Codex queue cannot reach the lead session +because the environment has no writable session database. Do not interpret an +unanswered coordination message as shared-source ownership approval. + +## Implemented result + +The [saved-run-set contract](../../../api/saved-run-sets.md) owns behavior and +limits. Native typed intent feeds existing Simulation; immutable manifests refer +to existing StepRecorder/RecordingReader archives. They do not own another store, +worker, numerical loop, runtime or restart mechanism. Full-intent declarations +bind trusted producer intent separately from actual archived fields/times/outcomes. +Cold reads never depend on current Debug rendering, current model code, live state +or a latest alias. Historical producer/build strings stay captured provenance. + +Composed-design reconciliation against the plan: + +1. Numerical acceptance, recording integrity, saved intent and analysis remain + separate owners; this module owns application binding only. +2. Required coupling is exact immutable spec ↔ archive declaration and finalized + artifact; display state and model channels do not enter the manifest. +3. Callers know the frozen spec, existing numerical API, protected directory and + close-before-pin obligation. The composer applies the declared source schedule. +4. Native control additions change this codec/composer; archive evolution remains + with recording; provider bindings require actual M1 types, not string guesses. +5. Only typed controls, identities and verified readers cross the boundary. +6. Missing/runtime-unavailable members cannot prevent cold numerical inspection; + this implementation invokes no runtime. Storage errors retain their typed state. +7. Deleting the manifest loses immutable grouping/intent; deleting it does not + remove a numerical archive or worker. No extra catalog/queue was retained. +8. Necessary version/hash/path/publication complexity is confined here; shared + registration and real desktop composition are explicitly pending. + +## Evidence and independent review + +Adopted Coding-Standards source-guided route: exact `188beda1fa477d21d576c233dd8c7f4c4c267d23` +Core/Router; Implementation, Verification/Oracles, Development Proportionality, +Commit, Documentation; Library, Persistence, Rust/API/Security; +Architecture/Code Design/Replay, Contracts/Evolution, Security and Resilience. +No unsafe, new dependency, numerical method, process supervisor, supported platform, +release or graphical workflow is introduced. This is not an engine qualification. + +Pinned Rust 1.92.0, locked dependencies, Linux CPU. Focused commands: + +```sh +cargo test --locked --features desktop --test saved_run_set_contract +cargo clippy --locked --features desktop --test saved_run_set_contract -- -D warnings +cargo fmt --all --check +``` + +Independent GPT-6.1 Sol High review challenged full source/time binding, current +Debug/version dependence, generic native schema decoding, real process-exit +fidelity, actual budget consistency and accepted horizon checks. All were repaired +with focused regression cases. Producer declaration is explicitly trusted intent, +not independent proof of source execution/authenticity. Final frozen independent review found no remaining material findings. Its fresh +focused run passed 12 tests (0 failed/ignored), including the real producer-process +exit before cold-reader launch. Focused Clippy with warnings denied and workspace +formatting passed. Reviewed source SHA256: +`a16ee7bc09b51c7a805946a3e0b6c97fcee58c6398e1c728662b0a826082af0d`; +test SHA256: +`ba16503db127ad98a67b754a98ecb9a0ca07c16b970e1941f5dfc2524d91d5f5`. +Broader tests and exact draft/CI results are reported before integration. + +The initial native fixture turned off its source after two steps and legitimately +hit the existing RHS compatibility gate on step three. It was replaced by a valid +three-step sourced fixture. No numerical gate or tolerance was weakened. + +## Remaining boundaries + +A3–A4 native automated portions have fresh evidence; accepted-provider A3 and +native desktop A4 remain pending. No accepted M1 production binding/physically +compatible composition was supplied to this worker, and shared source ownership +has not been agreed. Next dependent slice: lead supplies M1 outcome/provenance +contracts and allocates serial producer/UI registration; use those actual types +for provider recording, diagnostic schema, selected/fallback behavior and timings. +M3 comparison is a separate reviewed branch. Native GUI/model/lifecycle/other-OS +and full A0–A8 acceptance are not claimed. Existing archives are not restart +checkpoints and raw diagnostic JSON is untouched. + +## Continued acceptance preparation after first draft + +Parent directed continuation through the full M2–M3 assignment after initial +contract publication; these drafts are not the terminal milestone outcome. +Current lead source is diagnostic M1 draft PR87 at +`33a083a1fdf63c169c9bf68c71009d495d505f64`. It is main-based and deliberately +excludes unrelated training-viewer/voxel work from the older `0fbadbe` ancestry. +Only selected owned new files should enter its serial integration. Preserve the +original drafts and their exact source/review records; no ancestry-wide merge is +performed by this worker. + +The next local acceptance delta exposes the existing private manifest I/O helpers +at application crate scope for the real saved-comparison consumer. The caller +supplies its expected schema version and practical byte budget; zero and +`u64::MAX` budgets reject before oversize-detection arithmetic/I/O. Typed payload +validation remains with each document owner. Path checks and streamed file +identity reuse the same owner; this adds no numerical store or public Rust API. + +Four added tests use the existing numerical/recording owners: + +- A real numerical step reaches accepted generation 2 after recording capacity + admits only constructor and generation 1. The writer is poisoned, cannot claim + completion, and records failed terminal status with the unrecorded accepted + tail. A separately spawned cold reader observes that exact distinction; + missing ordinal/unknown field stays missing. +- A closed pinned member's ordinal 1 stays bit-identical while a second real + native track advances. This is in-process interleaving, not a threaded GUI + interaction claim. The old run-set revision never acquires the later final pin. +- Real SQLite `BEGIN IMMEDIATE` contention makes terminal publication fail busy. + The writer refuses retry, cold reopen stays active/incomplete, and pinning does + not overwrite/delete the archive. This proves the sampled lock failure, not + every possible uncertain commit outcome. + +Focused tests now pass 16/16 with warnings-denied Clippy. Independent GPT-6.1 Sol +High review found no remaining material finding in this delta. Reviewed source +SHA256 `1e73a290ae1b96a7bb295372bb276c2c992e17ca3295075321cfef73f428b692`; +test SHA256 `d9c1e521256b5e8cbf3550726d7e882eb370069dae5667d0d9de628673b2d995`. +Shared producer/UI and source-inventory integration are still serial lead writes +pending its concrete allocation. Saved recipe/result implementation is continuing +against these actual helpers and the reviewed native comparison source. + +### Existing-producer capture adapter + +`FrozenSpec::capture_constructor` reads the existing native owner's actual typed +geometry, configuration and implementation before the first accepted step. Its +caller supplies the future schedule and archive limits. It does not add a loop, +parse Debug strings, or permit accepted-state restart. A seventeenth real-native +test checks both implementations, exact frozen controls and rejection after a +step. Independent GPT-6.1 Sol High review found no material finding, independently +reran 17/17 tests, and confirmed reset generations are also refused. Focused +Clippy passed. Reviewed source SHA256 +`1c83658b8fea93cde74f7b2e12c8709fa21b8610595b35591f6fffb8a424d78e`; +test SHA256 +`0f38b3da20c6469023538284d34d1db32e8fa5e9a558a78fb75aa6580326b91f`. +This makes the producer hook concrete; desktop composition is still pending its +shared-path allocation and does not follow from this test. diff --git a/docs/plans/configurable-solver-components/reports/m23-persistence-comparison.md b/docs/plans/configurable-solver-components/reports/m23-persistence-comparison.md new file mode 100644 index 00000000..a7235bf0 --- /dev/null +++ b/docs/plans/configurable-solver-components/reports/m23-persistence-comparison.md @@ -0,0 +1,177 @@ +# M2–M3 clean persistence and comparison stack + +2026-10-15. Implementation is continuing; this report does not close A1–A8. +Integration lead owns shared source, qualification inventories and all merges. + +## Material and scope + +[Draft PR89](https://github.com/MrScripty/Rheon/pull/89), branch +`implementation/configurable-persistence-m23-20261015`, starts at M1 +`a527c42ee8ad1db05ee3088820de5b213f994466`. The lead's subsequent `50b40cf` +changes only its execution ledger; its later `f75a714` changes worker termination +qualification and is not yet in this stack's tested base. The stack selects the reviewed owned files +from [PR85](https://github.com/MrScripty/Rheon/pull/85) and +[PR86](https://github.com/MrScripty/Rheon/pull/86); their unrelated inherited +training/voxel-viewer ancestry is excluded. Original draft branches are preserved. +The initial combined source/codec commit is +`6f121082209df057729211178c997a81c3c8d46f`. + +The accepted-native slice freezes actual typed constructor controls, future dt, +source schedule, horizon and archive limits. Existing Simulation and +StepRecorder/RecordingReader remain the numerical and recording owners. A saved +set embeds immutable specs, exact differences and terminal archive pins. It has +no numerical arrays, mutable latest alias, restart checkpoint, worker, model +loader or alternate numerical store. Its actual configuration envelope and +publication uncertainty are specified in [saved-run-sets](../../../api/saved-run-sets.md). + +Native comparison selects explicit source ordinals but aligns actual stages and +physical times. Geometry and schema must match; no interpolation, particle-index +matching or display-pixel reduction is performed. Metrics use native fields, +including staggered-face divergence. Exact zero reference norm has unavailable +relative error; pressure mean removal remains an explicit diagnostic with the +removed offset retained. [Comparison semantics](../../../api/recording-comparison.md) +version this analysis separately from the archive and simulation. + +Saved recipes embed the complete immutable run-set revision, explicit source +members and selected publication identities. A saved result decodes as Unverified. +Only reopening the exact pinned source archives, matching each selection and +recomputing the complete result produces Verified. Missing, corrupt or changed +sources cannot substitute a live/latest publication. The codec reuses M2's bounded +atomic no-replace publication; it introduces no new store. Limits are 128 explicit +selections per source, 64 MiB aggregate selected field payload and 1 MiB document +size. The payload budget is not an RSS, total I/O or rendering-time guarantee. +[Saved-comparison contract](../../../api/comparison-archive.md) defines the boundary. + +## Fresh observed verification + +On the actual combined checkout at `6f12108`, Rust 1.92.0 Linux CPU: + +- `cargo test --locked --features desktop --test saved_run_set_contract --test comparison_archive_contract --test recording_comparison_contract` passed 17 + 12 + 15 tests, respectively. +- Focused desktop Clippy with `-D warnings` passed for those three targets. +- `cargo fmt --all --check` passed. +- `cargo test --locked --no-default-features --test recording_comparison_contract` passed 15 tests. + +The native tests execute both real Jacobi and SGS owners and preserve f32/f64 +field bits. An actual producer OS process exits before a separate reader starts; +the reader opens no model and checks selected bytes against SQLite BLOBs. Actual +capacity failure retains accepted generation 2 while generation 1 is the last +confirmed recorded interval. Cold reopen preserves the failed outcome and +unrecorded tail. Actual SQLite terminal-status contention leaves the archive +incomplete, without a guessed retry or overwrite. Selection stability under another +track's progress is an in-process interleaving test, not simultaneous GUI evidence. + +Saved-result tests also use real native producers and fresh reader processes. +Negative cases cover missing/moved/corrupt/changed sources, altered selection +identity, strict bounded/duplicate/versioned decoding, result content mutation and +rehashed plausible metric forgery. Independent read-only GPT-6.1 Sol High review +reran the focused tests and challenged rehashed unavailable outcomes, swapped +pair identities, forged relative-error nulls and generation substitution; the +actual source recomputation or selection check rejected each. No material finding +remained. Reviewed codec source SHA256: +`95f735ca5e82044115f962b1fe180bcb36b4a4584ddfb1c76b4b4acdb4e5f3a9`. + +The independently authored Python standard-library oracle reads SQLite/native +bits directly and verifies all six field roles, selection fingerprints and +nonempty real-native aligned coverage. It uses coordinate lookups and `math.fsum` +with an explicit propagated floating-point divergence bound. This checks the +stated analysis formulas for the sampled native fixtures, not physical accuracy +or accepted-provider qualification. + +PR86's exact original `c17efaa` CI passed the default, core-only and desktop feature +matrices, executable/native CPU/rational/conditional/coupling and VTK reader +checks. PR85 has additional source subjects; old results are not transferred to +new commits. PR89 checks are separate and pending at this report's creation. +The Pages source-inventory gate rejects unadmitted changed/added source. The lead +must review the complete integrated inventory and run fresh qualification on the +committed integration. No old receipt, inventory refresh without review, skipped +check or changed numerical gate supplies that evidence. + +## Environment and outstanding composition + +Executor access after the disconnect notification was confirmed by an actual +fetch and completed combined test run. Workspace-only official Debian signed +packages provided Xvfb/xdotool; the existing snapshot endpoints failed through +the proxy, while signed Debian trixie live indexes/packages succeeded. An +authenticated local X11 display, 1600×1100, reports Mesa 25.0.7 llvmpipe software +OpenGL 4.5. This qualifies the stated software-display environment; no hardware +GPU test is claimed. + +The cold saved-comparison page and seven headless egui interaction fixtures are +implemented in new owned files. Independent GPT-6.1 Sol High review reran those +seven tests, focused Clippy with warnings denied and formatting, and approved +exact source SHA256 +`32ce4a6c43cfd35a5ec4c3142ddae375fb60c4e87493c905dd71056f6e3d7934` +and test SHA256 +`dd1f625c696ca4542db38f837216ac0f234b5570e50abe647ced99d8773e33ff`. + +The exact page was also compiled with ordinary non-test configuration into a +temporary eframe verification harness (SHA256 +`3433b4bc45b8e81a2455d72b2a278f3ebc24f1db4359c5311183c6885ce3f010`). +Actual X11 pointer/keyboard interaction cold-opened an exported result as +Unverified, verified its pinned sources, changed field/ranges and computed three +pressure intervals with actual endpoint/dt/interval-start, Pa bounds and canonical +gauge labels. Other selected tracer data showed exact-zero relative-error +unavailability. A subsequent fresh window process exported a result, refused a +duplicate export while preserving its complete SHA256, then removed stale metrics +and showed Missing after a test archive was temporarily moved. The archive was +restored byte-for-byte. This final window exited with status 0; a fresh non-UI +reader process recomputed its four tracer pairs and zero unmatched publications. +Exported artifact SHA256: +`b59ef95a186b178b02dfed2436fe5f28d5d0627dc09d2660a4e2e6fadd8024b4`. +Generated logs/screens/fixtures remain outside the repository under +`/workspace/.rheon-gui`; harnesses remain under `/tmp/rheon-native-page-*`. +An earlier window process ended during another environment transition; that +interrupted process is not counted as a clean lifecycle pass. Subsequent actual +commands confirmed access, source hashes and the fresh window/reader results. + +This is narrow native cold-page interaction, not integrated root navigation, +linked slice/probe/camera, live worker Cancel/restart/Close or model execution. + +### Native presentation-data continuation + +The final owned core adds `compared_values`, reusing existing comparison/field +validation and the already returned gauge offset. It returns promoted native +arrays, exact pair summary, bit-identical geometry and explicit cell/face sampling. +Face dimensions retain the extra face, with zero offset along that axis; no +averaging, interpolation, replacement reduction, Scene/Frame fabrication or +numerical store is added. The cold page's explicit synchronous +`load_selected_fields()` rechecks source pins and selected identities. It performs +no source I/O for Empty/Unverified state, clears verification on source changes, +and preserves verified unavailable classifications without invented arrays. + +Fresh final combined desktop command (the four focused targets) passes +**17 persistence + 12 saved-comparison + 18 numerical + 9 UI tests**. Independent +GPT-6.1 Sol High review also freshly passes 18 numerical tests in desktop and +no-default modes, 9 UI tests, focused Clippy in both modes and formatting. The +pointwise SQLite/native-bit and coordinate/`math.fsum` oracle checks all six roles; +the original metric oracle is unchanged. No material finding remains. Final core +SHA256 `dca8613f68c20b38952d79b872d5233334f778cd467d695ed1d3102a7560edf0`; +UI SHA256 `a16da2595e1a735ddec98ee05b3364697921b65037649476af992fcecc8c04ed`. + +A freshly compiled ordinary-configuration X11 harness (source SHA256 +`2fc09fa4c5594bf1a7cff1bd9d64acc5c6b4ead8eac5adf7c58f740920b55fcc`) +exercised that final page: Unverified explicit load returned None; after Verify, +tracer materialization returned 60 cell-centered samples with dimensions [3,4,5] +and offsets [0.5,0.5,0.5]. Selecting native X velocity and recomputing returned 80 +X-face samples, dimensions [4,4,5], offsets [0,0.5,0.5]. Temporarily moving the +candidate archive caused explicit loading to return source-missing, clear old +metrics and show Missing. The exact fixture was restored and the window exited 0. +Generated log: `/workspace/.rheon-gui/native-page-projection-final.log`. +This observes the cold-page data consumer, not linked field rendering or final +root registration. The earlier harness source is preserved as +`/tmp/rheon-native-page-window-v1.rs`; no generated evidence enters the repository. + +Shared existing native `run_owned`/Inspector capture and +paired-view geometry/scalar/unit projection still await the lead's explicit +serial allocation or supplied hooks. Root navigation/worker lifecycle remains +lead-owned. These are concrete composition dependencies, not permission to add a +second loop, supervisor or renderer. + +M1's current raw AI diagnostic Scene/Frame evidence remains distinct from +accepted publications. Typed diagnostic storage, equal-request teacher capture, +stop-on-reject, warmup/fallback/count/cost presentation and physical qualification +of any production provider binding still require their actual owned contracts. +Native accepted-v1 records cannot invent those absent measurements. Diagnostic M1 +alone does not satisfy accepted-provider A3. The measured trained bundle, real +model inference, full native interaction, crash/power-loss durability and broader +platform acceptance remain unqualified by this stack. diff --git a/src/bin/desktop/comparison.rs b/src/bin/desktop/comparison.rs new file mode 100644 index 00000000..bd665d05 --- /dev/null +++ b/src/bin/desktop/comparison.rs @@ -0,0 +1,883 @@ +//! Cold native run-set inspection and exact comparison. This page owns UI state +//! only; numerical fields, source verification and artifact publication retain +//! their existing owners. Root registration and linked field rendering are separate. +use super::comparison_archive::{SavedRecipe, UnverifiedComparison, VerifiedComparison}; +use super::native_comparison::{ + ComparedValues, ComparedValuesError, Coverage, FieldComparison, FieldRole, PairComparison, + PressurePolicy, Recipe, Selection, Unavailable, compared_values, +}; +use super::run_sets::{ + MemberState, RunSetRevision, SpecDifference, archive_path, open_member, relative_path, +}; +use eframe::egui::{self, Color32}; +use rheon::SnapshotStage; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +const MAX_SELECTIONS: usize = 128; +const PATH_CHARACTERS: usize = 4096; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Availability { + Unchecked, + Ready, + NotRun, + Missing, + Incomplete, + Unpinned, + Unsupported, + Invalid, +} +#[derive(Debug, Clone)] +pub struct MemberSummary { + pub member_id: String, + pub availability: Availability, + pub detail: String, + pub recorded_count: Option, + pub recorded_generation: Option, + pub accepted_generation: Option, + pub unrecorded_tail: Option, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Presentation { + Empty, + Unverified, + Verified, +} +#[derive(Debug, Clone, Copy)] +struct PublicationTime { + endpoint: f64, + dt: Option, + interval_start: Option, + stage: SnapshotStage, +} +#[derive(Debug, Clone, Copy)] +struct PairTime { + reference: PublicationTime, + candidate: PublicationTime, +} + +/// Minimal seam for a future linked view; unavailable until exact verification. +/// Consumers must use the recipe's pinned source identities, never live Frames. +pub struct SelectedNativePair<'a> { + pub root: &'a Path, + pub recipe: &'a SavedRecipe, + pub pair: &'a PairComparison, +} + +#[derive(Debug)] +pub enum SelectedFieldsError { + Source(String), + Comparison(ComparedValuesError), +} +impl std::fmt::Display for SelectedFieldsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Source(reason) => write!(f, "selected native source: {reason}"), + Self::Comparison(error) => write!(f, "selected native fields: {error}"), + } + } +} +impl std::error::Error for SelectedFieldsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Comparison(error) => Some(error), + Self::Source(_) => None, + } + } +} + +pub struct SavedComparisonPage { + root: String, + manifest_path: String, + saved_artifact_path: String, + export_path: String, + loaded: Option, + origin: String, + members: Vec, + reference: String, + candidate: String, + reference_start: String, + reference_end: String, + candidate_start: String, + candidate_end: String, + field: FieldRole, + tolerance: String, + pressure: PressurePolicy, + pending_recipe: Option, + unverified: Option, + verified: Option, + times: Vec, + selected: usize, + message: String, + failed: bool, + #[cfg(test)] + controls: BTreeMap<&'static str, egui::Rect>, +} +impl Default for SavedComparisonPage { + fn default() -> Self { + Self { + root: ".".into(), + manifest_path: "run-set.json".into(), + saved_artifact_path: "comparison.json".into(), + export_path: "comparison-new.json".into(), + loaded: None, + origin: String::new(), + members: Vec::new(), + reference: String::new(), + candidate: String::new(), + reference_start: "0".into(), + reference_end: "0".into(), + candidate_start: "0".into(), + candidate_end: "0".into(), + field: FieldRole::Tracer, + tolerance: "0".into(), + pressure: PressurePolicy::Canonical, + pending_recipe: None, + unverified: None, + verified: None, + times: Vec::new(), + selected: 0, + message: "Open an immutable run-set manifest or saved comparison.".into(), + failed: false, + #[cfg(test)] + controls: BTreeMap::new(), + } + } +} +impl SavedComparisonPage { + /// Setting a source namespace invalidates every old verification and loaded binding. + pub fn set_location(&mut self, root: impl Into, manifest: impl Into) { + self.root = root.into(); + self.manifest_path = manifest.into(); + self.invalidate_binding(); + } + pub fn presentation(&self) -> Presentation { + if self.verified.is_some() { + Presentation::Verified + } else if self.unverified.is_some() { + Presentation::Unverified + } else { + Presentation::Empty + } + } + pub fn member_summaries(&self) -> &[MemberSummary] { + &self.members + } + pub fn message(&self) -> &str { + &self.message + } + pub fn selected_pair(&self) -> Option> { + let result = self.verified.as_ref()?; + Some(SelectedNativePair { + root: Path::new(&self.root), + recipe: result.recipe(), + pair: result.coverage().pairs.get(self.selected)?, + }) + } + /// Explicit synchronous materialization, not a per-frame getter. An + /// unverified/no-pair page returns None without source I/O. Callers may cache + /// successful arrays by the exact pair identities and recipe for rendering. + pub fn load_selected_fields(&mut self) -> Result, SelectedFieldsError> { + let result = (|| { + let Some(selected) = self.selected_pair() else { + return Ok(None); + }; + let mut sources = Vec::new(); + for (member_id, expected) in [ + ( + &selected.recipe.reference.member_id, + &selected.pair.reference, + ), + ( + &selected.recipe.candidate.member_id, + &selected.pair.candidate, + ), + ] { + let member = selected + .recipe + .run_set + .members + .iter() + .find(|m| &m.member_id == member_id) + .ok_or_else(|| SelectedFieldsError::Source("selected member missing".into()))?; + let reader = match open_member(selected.root, member) { + MemberState::Ready(reader) => reader, + MemberState::Missing => { + return Err(SelectedFieldsError::Source("exact archive missing".into())); + } + MemberState::Invalid(error) => { + return Err(SelectedFieldsError::Source(error.to_string())); + } + MemberState::Unsupported(reason) => { + return Err(SelectedFieldsError::Source(reason.into())); + } + _ => { + return Err(SelectedFieldsError::Source( + "exact archive is not ready and pinned".into(), + )); + } + }; + let source = Selection::read(&reader, expected.ordinal) + .map_err(|e| SelectedFieldsError::Source(e.to_string()))?; + if source.identity() != expected { + return Err(SelectedFieldsError::Source( + "exact selected publication changed".into(), + )); + } + sources.push(source); + } + let data = compared_values(&sources[0], &sources[1], selected.recipe.recipe) + .map_err(SelectedFieldsError::Comparison)?; + if data.comparison != *selected.pair { + return Err(SelectedFieldsError::Source( + "materialized summary differs from verified comparison".into(), + )); + } + Ok(Some(data)) + })(); + if let Err(error) = &result + && matches!( + error, + SelectedFieldsError::Source(_) + | SelectedFieldsError::Comparison(ComparedValuesError::Recipe(_)) + ) + { + self.fail(error); + } + result + } + fn clear_presented(&mut self) { + self.verified = None; + self.unverified = None; + self.times.clear(); + self.selected = 0; + } + fn invalidate_analysis(&mut self) { + self.clear_presented(); + self.pending_recipe = None; + self.failed = false; + self.message = "Selection changed. Compute a new comparison.".into(); + } + fn invalidate_binding(&mut self) { + self.invalidate_analysis(); + self.loaded = None; + self.members.clear(); + self.origin.clear(); + self.reference.clear(); + self.candidate.clear(); + self.message = "Source location changed. Open the intended artifact again.".into(); + } + fn fail(&mut self, error: impl ToString) { + self.clear_presented(); + self.pending_recipe = None; + if let Some(set) = &self.loaded { + self.members = summaries(set, Some(Path::new(&self.root))); + } + self.failed = true; + self.message = error.to_string(); + } + fn source_path(&self, relative: &str) -> Result { + relative_path(relative).map_err(|e| e.to_string())?; + archive_path(Path::new(&self.root), relative).map_err(|e| e.to_string()) + } + fn destination(&self) -> Result { + relative_path(&self.export_path).map_err(|e| e.to_string())?; + if let Some(parent) = Path::new(&self.export_path) + .parent() + .filter(|p| !p.as_os_str().is_empty()) + { + archive_path( + Path::new(&self.root), + parent.to_str().ok_or("UTF-8 destination required")?, + ) + .map_err(|e| e.to_string())?; + } + Ok(Path::new(&self.root).join(&self.export_path)) + } + pub fn load_manifest(&mut self) { + self.invalidate_binding(); + let result = self + .source_path(&self.manifest_path) + .and_then(|p| RunSetRevision::open(&p).map_err(|e| e.to_string())); + match result { + Ok(set) => { + self.reference = set.baseline_member.clone(); + self.candidate = set + .members + .iter() + .find(|m| m.member_id != self.reference) + .map_or_else(|| self.reference.clone(), |m| m.member_id.clone()); + self.members = summaries(&set, Some(Path::new(&self.root))); + self.loaded = Some(set); + self.reset_ranges(); + self.origin = "Opened manifest revision".into(); + self.failed = false; + self.message = "Source status checked. Select publications and compute.".into(); + } + Err(e) => self.fail(e), + } + } + fn reset_ranges(&mut self) { + for (id, start, end) in [ + ( + &self.reference, + &mut self.reference_start, + &mut self.reference_end, + ), + ( + &self.candidate, + &mut self.candidate_start, + &mut self.candidate_end, + ), + ] { + *start = "0".into(); + *end = self + .members + .iter() + .find(|m| &m.member_id == id) + .and_then(|m| m.recorded_count) + .map_or(0, |n| n.saturating_sub(1).min(MAX_SELECTIONS - 1)) + .to_string(); + } + } + fn install_recipe_inputs(&mut self, recipe: &SavedRecipe) { + self.loaded = Some(recipe.run_set.clone()); + self.members = summaries(&recipe.run_set, None); + self.reference = recipe.reference.member_id.clone(); + self.candidate = recipe.candidate.member_id.clone(); + self.reference_start = recipe + .reference + .selections + .first() + .unwrap() + .ordinal + .to_string(); + self.reference_end = recipe + .reference + .selections + .last() + .unwrap() + .ordinal + .to_string(); + self.candidate_start = recipe + .candidate + .selections + .first() + .unwrap() + .ordinal + .to_string(); + self.candidate_end = recipe + .candidate + .selections + .last() + .unwrap() + .ordinal + .to_string(); + self.field = recipe.recipe.field; + self.tolerance = recipe.recipe.time_tolerance.to_string(); + self.pressure = recipe.recipe.pressure; + } + pub fn open_saved_recipe(&mut self) { + self.invalidate_binding(); + let result = self + .source_path(&self.saved_artifact_path) + .and_then(|p| SavedRecipe::open(&p).map_err(|e| e.to_string())); + match result { + Ok(recipe) => { + self.install_recipe_inputs(&recipe); + self.pending_recipe = Some(recipe); + self.origin = "Embedded saved recipe revision".into(); + self.failed = false; + self.message = "Pinned recipe opened. Compute checks its exact source pins.".into(); + } + Err(e) => self.fail(e), + } + } + pub fn open_saved_result(&mut self) { + self.invalidate_binding(); + let result = self + .source_path(&self.saved_artifact_path) + .and_then(|p| UnverifiedComparison::open(&p).map_err(|e| e.to_string())); + match result { + Ok(raw) => { + self.install_recipe_inputs(raw.recipe()); + self.pending_recipe = Some(raw.recipe().clone()); + self.unverified = Some(raw); + self.origin = "Embedded saved result revision".into(); + self.failed = false; + self.message = + "Unverified stored result. Verify exact sources before using these metrics." + .into(); + } + Err(e) => self.fail(e), + } + } + fn input_recipe(&self) -> Result { + let recipe = Recipe { + field: self.field, + time_tolerance: self + .tolerance + .parse() + .map_err(|_| "Time tolerance must be a finite nonnegative number of seconds")?, + pressure: self.pressure, + }; + recipe.validate().map_err(|e| e.to_string())?; + Ok(recipe) + } + pub fn compute(&mut self) { + self.clear_presented(); + let result = (|| { + let recipe = if let Some(pinned) = &self.pending_recipe { + pinned.clone() + } else { + let set = self + .loaded + .as_ref() + .ok_or("Open an immutable source revision first")?; + SavedRecipe::capture( + Path::new(&self.root), + set.clone(), + &self.reference, + &self.candidate, + &ordinals(&self.reference_start, &self.reference_end)?, + &ordinals(&self.candidate_start, &self.candidate_end)?, + self.input_recipe()?, + ) + .map_err(|e| e.to_string())? + }; + recipe + .compute(Path::new(&self.root)) + .map_err(|e| e.to_string()) + })(); + self.finish_computation(result); + } + pub fn verify_saved_result(&mut self) { + let raw = self.unverified.take(); + self.clear_presented(); + let result = raw + .ok_or_else(|| "Open a saved result before verification".into()) + .and_then(|raw| raw.verify(Path::new(&self.root)).map_err(|e| e.to_string())); + self.finish_computation(result); + } + fn finish_computation(&mut self, result: Result) { + match result.and_then(|verified| { + let times = pair_times( + Path::new(&self.root), + verified.recipe(), + verified.coverage(), + )?; + Ok((verified, times)) + }) { + Ok((verified, times)) => { + self.members = summaries(&verified.recipe().run_set, Some(Path::new(&self.root))); + if [ + &verified.recipe().reference.member_id, + &verified.recipe().candidate.member_id, + ] + .iter() + .any(|id| { + self.members + .iter() + .find(|s| &s.member_id == *id) + .is_none_or(|s| s.availability != Availability::Ready) + }) { + self.fail( + "Exact selected source became unavailable during the source-status check", + ); + return; + } + self.pending_recipe = Some(verified.recipe().clone()); + self.verified = Some(verified); + self.times = times; + self.failed = false; + self.message="Verified against exact recorded sources. Derived analysis; physics acceptance is separate.".into(); + } + Err(e) => self.fail(e), + } + } + pub fn export_recipe(&mut self) { + self.export(false); + } + pub fn export_result(&mut self) { + self.export(true); + } + fn export(&mut self, result: bool) { + let outcome = (|| { + let verified = self + .verified + .as_ref() + .ok_or("Compute or verify a comparison before export")?; + let path = self.destination()?; + if result { + verified.save_new(&path) + } else { + verified.recipe().save_new(&path) + } + .map_err(|e| e.to_string())?; + Ok::<_, String>(path) + })(); + match outcome { + Ok(path) => { + self.failed = false; + self.message = format!( + "Published immutable {}: {}", + if result { "result" } else { "recipe" }, + path.display() + ); + } + Err(e) => self.fail(e), + } + } + fn remember(&mut self, _id: &'static str, response: egui::Response) -> egui::Response { + #[cfg(test)] + { + self.controls.insert(_id, response.rect); + } + response + } + #[cfg(test)] + pub fn control_rect(&self, id: &str) -> Option { + self.controls.get(id).copied() + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + #[cfg(test)] + self.controls.clear(); + egui::ScrollArea::vertical().id_salt("saved-native-comparison").show(ui,|ui|{ + ui.heading("Saved native comparisons"); + ui.label("Inspect immutable run sets and compare exact recorded publications."); + ui.horizontal(|ui|{ui.label("Dataset root");let r=ui.add(egui::TextEdit::singleline(&mut self.root).char_limit(PATH_CHARACTERS));if self.remember("root",r).changed(){self.invalidate_binding();}}); + ui.horizontal(|ui|{ui.label("Manifest relative path");let r=ui.add(egui::TextEdit::singleline(&mut self.manifest_path).char_limit(PATH_CHARACTERS));if self.remember("manifest",r).changed(){self.invalidate_binding();}let r=ui.button("Open manifest");if self.remember("open-manifest",r).clicked(){self.load_manifest();}}); + ui.horizontal(|ui|{ + ui.label("Saved artifact relative path");let r=ui.add(egui::TextEdit::singleline(&mut self.saved_artifact_path).char_limit(PATH_CHARACTERS));if self.remember("saved-path",r).changed(){self.invalidate_binding();} + let r=ui.button("Open recipe");if self.remember("open-recipe",r).clicked(){self.open_saved_recipe();} + let r=ui.button("Open result");if self.remember("open-result",r).clicked(){self.open_saved_result();} + }); + ui.separator(); + if let Some(set)=&self.loaded { + ui.label(format!("{} · set {} · baseline {}",self.origin,set.set_id,set.baseline_member)); + for m in &set.members { + let summary=self.members.iter().find(|s|s.member_id==m.member_id); + ui.collapsing(format!("{} · {}",m.member_id,summary.map_or("Not checked",|s|availability_label(s.availability))),|ui|{ + if let Some(s)=summary {ui.label(&s.detail);} + ui.label(format!("Declared differences: {}",if m.differences_from_baseline.is_empty(){"none".into()}else{m.differences_from_baseline.iter().map(|d|difference_label(*d)).collect::>().join(", ")})); + ui.label(format!("Intended method {} · {} intervals at {:.17e} s",m.spec.spec.pressure_method.implementation().label(),m.spec.spec.requested_steps,m.spec.spec.requested_dt_s)); + ui.label(format!("Intended grid {:?}, spacing {:?} m, origin {:?} m",m.spec.spec.counts_xyz,m.spec.spec.spacing_m,m.spec.spec.origin_m)); + if let Some(a)=&m.attempt {ui.monospace(format!("attempt {} · run {} · source {}\narchive {}",a.attempt_id,a.expected_run_id,a.expected_source_revision,a.archive_relative_path));if let Some(pin)=&a.finalized {ui.monospace(format!("archive SHA256 {}",hex(pin.sha256)));}} + }); + } + } + let ids:Vec<_>=self.loaded.as_ref().map_or_else(Vec::new,|set|set.members.iter().map(|m|m.member_id.clone()).collect()); + if !self.members.is_empty(){ui.small("Availability is checked on explicit load, compute or verification; sources are not monitored in the background.");} + let mut changed=false; + ui.horizontal(|ui|{ + ui.label("Reference");let response = egui::ComboBox::from_id_salt("saved-reference").selected_text(&self.reference).show_ui(ui,|ui|{for id in &ids {changed|=ui.selectable_value(&mut self.reference,id.clone(),id).changed();}}); let _=self.remember("reference-member",response.response); + ui.label("Candidate");let response = egui::ComboBox::from_id_salt("saved-candidate").selected_text(&self.candidate).show_ui(ui,|ui|{for id in &ids {changed|=ui.selectable_value(&mut self.candidate,id.clone(),id).changed();}}); let _=self.remember("candidate-member",response.response); + }); + ui.label("Inclusive source ordinals (1–128 explicit publications per side)"); + ui.horizontal(|ui|{ + ui.label("Reference");let r=ui.add(egui::TextEdit::singleline(&mut self.reference_start).desired_width(60.0).char_limit(20));changed|=self.remember("reference-start",r).changed();ui.label("through");let r=ui.add(egui::TextEdit::singleline(&mut self.reference_end).desired_width(60.0).char_limit(20));changed|=self.remember("reference-end",r).changed(); + ui.label("Candidate");let r=ui.add(egui::TextEdit::singleline(&mut self.candidate_start).desired_width(60.0).char_limit(20));changed|=self.remember("candidate-start",r).changed();ui.label("through");let r=ui.add(egui::TextEdit::singleline(&mut self.candidate_end).desired_width(60.0).char_limit(20));changed|=self.remember("candidate-end",r).changed(); + }); + ui.horizontal(|ui|{ + ui.label("Field");let response = egui::ComboBox::from_id_salt("saved-role").selected_text(role_label(self.field)).show_ui(ui,|ui|{for role in ROLES {changed|=ui.selectable_value(&mut self.field,role,role_label(role)).changed();}}); let _=self.remember("role",response.response); + ui.label("Time tolerance (s)");let r=ui.add(egui::TextEdit::singleline(&mut self.tolerance).desired_width(100.0).char_limit(32));changed|=self.remember("tolerance",r).changed(); + let response=egui::ComboBox::from_id_salt("saved-gauge").selected_text(if self.pressure==PressurePolicy::Canonical{"Canonical pressure gauge"}else{"Remove mean offset (diagnostic)"}).show_ui(ui,|ui|{ + changed|=ui.selectable_value(&mut self.pressure,PressurePolicy::Canonical,"Canonical pressure gauge").changed();changed|=ui.selectable_value(&mut self.pressure,PressurePolicy::RemoveMeanOffsetDiagnostic,"Remove mean offset (diagnostic)").changed(); + }); let _=self.remember("gauge",response.response); + }); + if changed {self.invalidate_analysis();} + if let Some(pinned)=&self.pending_recipe { + ui.small(format!("Exact pinned ordinals: reference {:?}; candidate {:?}. Editing inputs creates a new recipe.",pinned.reference.selections.iter().map(|s|s.ordinal).collect::>(),pinned.candidate.selections.iter().map(|s|s.ordinal).collect::>())); + } + ui.horizontal(|ui|{ + let r=ui.add_enabled(self.loaded.is_some(),egui::Button::new("Compute exact comparison"));if self.remember("compute",r).clicked(){self.compute();} + let r=ui.add_enabled(self.unverified.is_some(),egui::Button::new("Verify saved result sources"));if self.remember("verify",r).clicked(){self.verify_saved_result();} + }); + ui.horizontal(|ui|{ + ui.label("New export relative path");let r=ui.add(egui::TextEdit::singleline(&mut self.export_path).char_limit(PATH_CHARACTERS));let _=self.remember("export-path",r); + let r=ui.add_enabled(self.verified.is_some(),egui::Button::new("Export recipe"));if self.remember("export-recipe",r).clicked(){self.export_recipe();} + let r=ui.add_enabled(self.verified.is_some(),egui::Button::new("Export result"));if self.remember("export-result",r).clicked(){self.export_result();} + }); + ui.colored_label(if self.failed {Color32::LIGHT_RED}else{Color32::LIGHT_BLUE},&self.message); + ui.small("Native archive v1 has no typed timing, provider-attempt, warmup, fallback or RHS diagnostics. Those facts are unavailable."); + ui.separator();self.show_coverage(ui); + }); + } + fn show_coverage(&mut self, ui: &mut egui::Ui) { + let verified = self.verified.is_some(); + let coverage = if let Some(v) = &self.verified { + v.coverage() + } else if let Some(v) = &self.unverified { + v.recorded_coverage() + } else { + return; + }; + ui.colored_label( + if verified { + Color32::LIGHT_GREEN + } else { + Color32::YELLOW + }, + if verified { + "Verified recorded-source comparison" + } else { + "UNVERIFIED stored evidence — exact sources have not been checked" + }, + ); + ui.label(format!( + "{} temporal pairs · {} reference unmatched · {} candidate unmatched", + coverage.pairs.len(), + coverage.reference_unmatched.len(), + coverage.candidate_unmatched.len() + )); + for (side, unmatched) in [ + ("Reference", &coverage.reference_unmatched), + ("Candidate", &coverage.candidate_unmatched), + ] { + if !unmatched.is_empty() { + ui.label(format!( + "{side} unmatched ordinals: {:?}", + unmatched.iter().map(|s| s.ordinal).collect::>() + )); + } + } + if coverage.pairs.is_empty() { + ui.label("No matched physical publications. No numerical metric is available."); + return; + } + ui.add( + egui::Slider::new(&mut self.selected, 0..=coverage.pairs.len() - 1) + .text("Matched pair"), + ); + let pair = &coverage.pairs[self.selected]; + ui.label(format!( + "Reference ordinal {}, generation {} · candidate ordinal {}, generation {}", + pair.reference.ordinal, + pair.reference.generation, + pair.candidate.ordinal, + pair.candidate.generation + )); + if let Some(time) = self.times.get(self.selected).filter(|_| verified) { + for (name, t) in [("Reference", time.reference), ("Candidate", time.candidate)] { + ui.monospace(format!( + "{name}: actual endpoint {:.17e} s · stage {}", + t.endpoint, + if t.stage == SnapshotStage::Accepted { + "accepted" + } else { + "constructor" + } + )); + if let Some(dt) = t.dt { + ui.monospace(format!("actual dt {dt:.17e} s")); + } + if let Some(start) = t.interval_start { + ui.monospace(format!( + "projection interval [{start:.17e}, {:.17e}] s", + t.endpoint + )); + } + } + } else { + ui.label("Actual source time/stage has not been reopened for this stored result."); + } + match &pair.outcome { + Ok(field) => show_field(ui, field), + Err(reason) => { + ui.colored_label( + Color32::YELLOW, + format!("Metric unavailable: {}", unavailable_label(*reason)), + ); + } + } + ui.collapsing("Exact selected source identities", |ui| { + for (name, id) in [ + ("Reference", &pair.reference), + ("Candidate", &pair.candidate), + ] { + ui.monospace(format!( + "{name}: run {} · source {}\npublication SHA256 {}", + id.run_id, + id.source_revision, + hex(id.sha256) + )); + } + }); + } +} +const ROLES: [FieldRole; 6] = [ + FieldRole::Tracer, + FieldRole::IntervalPressure, + FieldRole::VelocityX, + FieldRole::VelocityY, + FieldRole::VelocityZ, + FieldRole::VelocityDivergence, +]; +fn ordinals(start: &str, end: &str) -> Result, String> { + let start: usize = start + .parse() + .map_err(|_| "Ordinal start must be a nonnegative integer")?; + let end: usize = end + .parse() + .map_err(|_| "Ordinal end must be a nonnegative integer")?; + let count = end + .checked_sub(start) + .and_then(|n| n.checked_add(1)) + .filter(|n| *n <= MAX_SELECTIONS) + .ok_or("Select an increasing inclusive range of at most 128 publications")?; + Ok((0..count).map(|i| start + i).collect()) +} +fn summaries(set: &RunSetRevision, root: Option<&Path>) -> Vec { + set.members.iter().map(|m|{ + let mut view=MemberSummary {member_id:m.member_id.clone(),availability:Availability::Unchecked,detail:"Source not checked in this operation".into(),recorded_count:None,recorded_generation:None,accepted_generation:None,unrecorded_tail:None}; + let Some(root)=root else{return view;}; + match open_member(root,m) { + MemberState::Ready(reader)=>{ + let facts=(||{let count=reader.count()?;let last=if count==0{None}else{Some(reader.select(count-1)?.generation)};Ok::<_,rheon::recording::RecordingError>((count,last,reader.status()?))})(); + match facts {Ok((count,last,status))=>{ + view.availability=Availability::Ready;view.recorded_count=Some(count);view.recorded_generation=last;view.accepted_generation=status.accepted_generation; + view.unrecorded_tail=Some(status.accepted_generation.is_some_and(|g|last.is_none_or(|last|g>last))); + view.detail=format!("Terminal {} · {} recorded publications · recorded generation {:?} · accepted generation {:?} · unrecorded accepted tail {}",status.outcome,count,last,status.accepted_generation,if view.unrecorded_tail==Some(true){"present"}else{"absent"}); + },Err(e)=>{view.availability=Availability::Invalid;view.detail=e.to_string();}} + }, + MemberState::NotRun=>{view.availability=Availability::NotRun;view.detail="No run attempt saved".into();}, + MemberState::Missing=>{view.availability=Availability::Missing;view.detail="Exact referenced archive is missing".into();}, + MemberState::Incomplete{recorded_count,status}=>{view.availability=Availability::Incomplete;view.recorded_count=Some(recorded_count);view.accepted_generation=status.accepted_generation;view.detail=format!("Incomplete {} · {} recorded publications · accepted generation {:?}; recorded tail not inspected",status.outcome,recorded_count,status.accepted_generation);}, + MemberState::Unpinned{recorded_count,status}=>{view.availability=Availability::Unpinned;view.recorded_count=Some(recorded_count);view.accepted_generation=status.accepted_generation;view.detail=format!("Unpinned terminal {} · {} recorded publications · accepted generation {:?}",status.outcome,recorded_count,status.accepted_generation);}, + MemberState::Unsupported(e)=>{view.availability=Availability::Unsupported;view.detail=e.into();}, + MemberState::Invalid(e)=>{view.availability=Availability::Invalid;view.detail=e.to_string();}, + }view + }).collect() +} +fn pair_times( + root: &Path, + recipe: &SavedRecipe, + coverage: &Coverage, +) -> Result, String> { + let mut sources = Vec::new(); + for selected in [&recipe.reference, &recipe.candidate] { + let member = recipe + .run_set + .members + .iter() + .find(|m| m.member_id == selected.member_id) + .ok_or("Selected member missing")?; + let reader = match open_member(root, member) { + MemberState::Ready(r) => r, + MemberState::Missing => { + return Err("Exact source missing while reading actual publication times".into()); + } + MemberState::Invalid(e) => return Err(e.to_string()), + MemberState::Unsupported(e) => return Err(e.into()), + _ => return Err("Exact source is no longer a ready pinned archive".into()), + }; + let mut times = BTreeMap::new(); + for expected in &selected.selections { + let source = Selection::read(&reader, expected.ordinal).map_err(|e| e.to_string())?; + if source.identity() != expected { + return Err("Exact source changed while reading publication times".into()); + } + let p = source.publication(); + times.insert( + expected.ordinal, + PublicationTime { + endpoint: p.time, + dt: p.dt, + interval_start: if recipe.recipe.field == FieldRole::IntervalPressure { + p.field("interval_pressure").and_then(|f| f.evaluation_time) + } else { + None + }, + stage: p.stage, + }, + ); + } + sources.push(times); + } + coverage + .pairs + .iter() + .map(|p| { + Ok(PairTime { + reference: *sources[0] + .get(&p.reference.ordinal) + .ok_or("Reference publication time missing")?, + candidate: *sources[1] + .get(&p.candidate.ordinal) + .ok_or("Candidate publication time missing")?, + }) + }) + .collect() +} +fn show_field(ui: &mut egui::Ui, field: &FieldComparison) { + let m = field.metrics; + ui.monospace(format!( + "L2 {:.17e} {}\nRMSE {:.17e} {}\nL∞ {:.17e} {}\nsupport {} native samples", + m.l2, field.units, m.rmse, field.units, m.linf, field.units, m.support_count + )); + ui.monospace(m.relative_l2.map_or_else( + || "Relative L2 unavailable: reference norm is zero".into(), + |v| format!("Relative L2 {v:.17e}"), + )); + ui.monospace(format!( + "Common bounds [{:.17e}, {:.17e}] {}", + field.color_bounds.min, field.color_bounds.max, field.units + )); + if let Some(g) = field.gauge { + ui.label(format!("Pressure gauge: reference canonical {}, candidate canonical {} · {} · removed offset {:.17e} Pa",g.reference_canonical,g.candidate_canonical,if g.diagnostic{"offset-aligned diagnostic"}else{"direct canonical comparison"},g.removed_offset_pa)); + } +} +fn hex(value: [u8; 32]) -> String { + value.iter().map(|b| format!("{b:02x}")).collect() +} +fn availability_label(a: Availability) -> &'static str { + match a { + Availability::Unchecked => "Not checked", + Availability::Ready => "Ready", + Availability::NotRun => "Not run", + Availability::Missing => "Missing", + Availability::Incomplete => "Incomplete", + Availability::Unpinned => "Unpinned", + Availability::Unsupported => "Unsupported", + Availability::Invalid => "Invalid", + } +} +fn role_label(r: FieldRole) -> &'static str { + match r { + FieldRole::Tracer => "Tracer", + FieldRole::IntervalPressure => "Interval pressure", + FieldRole::VelocityX => "Velocity X", + FieldRole::VelocityY => "Velocity Y", + FieldRole::VelocityZ => "Velocity Z", + FieldRole::VelocityDivergence => "Velocity divergence", + } +} +fn difference_label(d: SpecDifference) -> &'static str { + match d { + SpecDifference::Geometry => "geometry", + SpecDifference::Density => "density", + SpecDifference::PressureMethod => "pressure method", + SpecDifference::Tolerances => "tolerances", + SpecDifference::TimePolicy => "time policy", + SpecDifference::Source => "source schedule", + SpecDifference::MemoryBudget => "memory budget", + SpecDifference::RecordingBudget => "recording budget", + } +} +fn unavailable_label(r: Unavailable) -> &'static str { + match r { + Unavailable::UnsupportedProfile => "unsupported source profile", + Unavailable::Geometry => "native geometry differs", + Unavailable::PublicationStage => "publication stages differ", + Unavailable::PhysicalTime => "physical times or intervals differ", + Unavailable::MissingField => "recorded field is absent", + Unavailable::FieldSchema => "native field schema is incompatible", + Unavailable::NoncanonicalGauge => "canonical pressure gauge failed", + Unavailable::EmptySupport => "empty native support", + Unavailable::ArithmeticRange => "arithmetic result is not finite", + } +} diff --git a/src/bin/desktop/comparison_archive.rs b/src/bin/desktop/comparison_archive.rs new file mode 100644 index 00000000..70f8be5e --- /dev/null +++ b/src/bin/desktop/comparison_archive.rs @@ -0,0 +1,535 @@ +//! Immutable derived native comparison evidence over M2's exact run-set sources. +//! Opening a saved result never verifies physics or silently resolves newer data. +use super::native_comparison::{ + ANALYSIS_VERSION, Coverage, FieldComparison, FieldRole, PressurePolicy, Recipe, RecipeError, + Selection, SelectionIdentity, align, +}; +use super::run_sets::{ + MemberState, RunSetError, RunSetMember, RunSetRevision, open_member, publish_new, + read_versioned, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fmt, path::Path}; + +const SCHEMA_VERSION: u32 = 1; +const MAX_DOCUMENT_BYTES: u64 = 1024 * 1024; +const MAX_SELECTIONS_PER_SOURCE: usize = 128; +const MAX_SELECTED_FIELD_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Reference, + Candidate, +} +#[derive(Debug)] +pub enum SourceProblem { + Missing, + Incomplete, + Unpinned, + NotRun, + Unsupported(&'static str), + Invalid(RunSetError), + SelectionChanged, +} +impl fmt::Display for SourceProblem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing => write!(f, "missing exact archive"), + Self::Incomplete => write!(f, "archive incomplete"), + Self::Unpinned => write!(f, "archive not pinned"), + Self::NotRun => write!(f, "member not run"), + Self::Unsupported(reason) => write!(f, "unsupported archive: {reason}"), + Self::Invalid(error) => write!(f, "invalid archive: {error}"), + Self::SelectionChanged => write!(f, "exact publication identity changed"), + } + } +} +#[derive(Debug)] +pub enum ComparisonArchiveError { + Artifact(RunSetError), + Recipe(RecipeError), + Invalid(&'static str), + Unsupported(&'static str), + Integrity(&'static str), + Source { side: Side, problem: SourceProblem }, +} +impl fmt::Display for ComparisonArchiveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Artifact(e) => write!(f, "comparison artifact: {e}"), + Self::Recipe(e) => write!(f, "comparison alignment: {e}"), + Self::Invalid(r) => write!(f, "invalid comparison artifact: {r}"), + Self::Unsupported(r) => write!(f, "unsupported comparison artifact: {r}"), + Self::Integrity(r) => write!(f, "comparison artifact integrity: {r}"), + Self::Source { side, problem } => write!(f, "comparison {side:?} source: {problem}"), + } + } +} +impl std::error::Error for ComparisonArchiveError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Artifact(e) => Some(e), + Self::Recipe(e) => Some(e), + Self::Source { + problem: SourceProblem::Invalid(e), + .. + } => Some(e), + _ => None, + } + } +} +impl From for ComparisonArchiveError { + fn from(e: RunSetError) -> Self { + Self::Artifact(e) + } +} +impl From for ComparisonArchiveError { + fn from(e: RecipeError) -> Self { + Self::Recipe(e) + } +} + +type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SelectedMember { + pub member_id: String, + /// Ordered explicit source ordinals and exact analysis-v1 identities. + pub selections: Vec, +} + +/// Immutable intent; source existence/identity is checked by `compute`, not `open`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SavedRecipe { + pub analysis_version: u32, + pub recipe: Recipe, + /// Full immutable M2 revision; its members retain terminal archive file pins. + pub run_set: RunSetRevision, + pub run_set_sha256: [u8; 32], + pub reference: SelectedMember, + pub candidate: SelectedMember, +} +impl SavedRecipe { + /// Capture explicit publications from closed, pinned native sources only. + pub fn capture( + root: &Path, + run_set: RunSetRevision, + reference_member: &str, + candidate_member: &str, + reference_ordinals: &[usize], + candidate_ordinals: &[usize], + recipe: Recipe, + ) -> Result { + recipe.validate()?; + run_set.validate()?; + let mut selected_bytes = 0; + let reference = capture_member( + root, + &run_set, + reference_member, + reference_ordinals, + Side::Reference, + &mut selected_bytes, + )?; + let candidate = capture_member( + root, + &run_set, + candidate_member, + candidate_ordinals, + Side::Candidate, + &mut selected_bytes, + )?; + let value = Self { + analysis_version: ANALYSIS_VERSION, + recipe, + run_set_sha256: run_set.sha256()?, + run_set, + reference, + candidate, + }; + value.validate()?; + Ok(value) + } + pub fn validate(&self) -> Result<()> { + analysis_version(self.analysis_version)?; + self.recipe.validate()?; + self.run_set.validate()?; + if self.run_set_sha256 != self.run_set.sha256()? { + return Err(ComparisonArchiveError::Integrity("run-set revision digest")); + } + for selected in [&self.reference, &self.candidate] { + validate_selected(&self.run_set, selected)?; + } + Ok(()) + } + pub fn sha256(&self) -> Result<[u8; 32]> { + self.validate()?; + digest(self) + } + pub fn save_new(&self, path: &Path) -> Result<()> { + self.validate()?; + save(path, ArtifactKind::Recipe, self) + } + /// Decode/hash validation only; no archive, worker or model is opened. + pub fn open(path: &Path) -> Result { + let envelope: Envelope = read_versioned(path, SCHEMA_VERSION, MAX_DOCUMENT_BYTES)?; + validate_envelope(&envelope, ArtifactKind::Recipe)?; + envelope.payload.validate()?; + Ok(envelope.payload) + } + pub fn compute(&self, root: &Path) -> Result { + self.validate()?; + let mut selected_bytes = 0; + let reference = load_selected( + root, + &self.run_set, + &self.reference, + Side::Reference, + &mut selected_bytes, + )?; + let candidate = load_selected( + root, + &self.run_set, + &self.candidate, + Side::Candidate, + &mut selected_bytes, + )?; + let coverage = align(&reference, &candidate, self.recipe)?; + let record = ComparisonRecord { + recipe: self.clone(), + coverage, + }; + record.validate()?; + Ok(VerifiedComparison { record }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct ComparisonRecord { + recipe: SavedRecipe, + coverage: Coverage, +} +impl ComparisonRecord { + fn validate(&self) -> Result<()> { + self.recipe.validate()?; + analysis_version(self.coverage.analysis_version)?; + if self.coverage.recipe != self.recipe.recipe { + return Err(ComparisonArchiveError::Invalid("result recipe binding")); + } + for pair in &self.coverage.pairs { + analysis_version(pair.analysis_version)?; + if pair.recipe != self.recipe.recipe { + return Err(ComparisonArchiveError::Invalid("pair recipe binding")); + } + if let Ok(field) = &pair.outcome { + validate_field(field, self.recipe.recipe)?; + } + } + let reference = self + .coverage + .pairs + .iter() + .map(|p| &p.reference) + .chain(self.coverage.reference_unmatched.iter()) + .collect::>(); + let candidate = self + .coverage + .pairs + .iter() + .map(|p| &p.candidate) + .chain(self.coverage.candidate_unmatched.iter()) + .collect::>(); + for (selected, actual) in [ + (&self.recipe.reference, reference), + (&self.recipe.candidate, candidate), + ] { + if selected.selections.len() != actual.len() + || selected + .selections + .iter() + .any(|s| actual.iter().filter(|a| ***a == *s).count() != 1) + { + return Err(ComparisonArchiveError::Invalid("result selection coverage")); + } + } + Ok(()) + } +} + +/// A decoded result remains unverified until exact source recomputation succeeds. +pub struct UnverifiedComparison { + record: ComparisonRecord, +} +impl UnverifiedComparison { + pub fn open(path: &Path) -> Result { + let envelope: Envelope = + read_versioned(path, SCHEMA_VERSION, MAX_DOCUMENT_BYTES)?; + validate_envelope(&envelope, ArtifactKind::Result)?; + envelope.payload.validate()?; + Ok(Self { + record: envelope.payload, + }) + } + pub fn recipe(&self) -> &SavedRecipe { + &self.record.recipe + } + pub fn recorded_coverage(&self) -> &Coverage { + &self.record.coverage + } + pub fn verify(&self, root: &Path) -> Result { + let recomputed = self.record.recipe.compute(root)?; + if digest(&self.record.coverage)? != digest(&recomputed.record.coverage)? { + return Err(ComparisonArchiveError::Integrity( + "result differs from exact source recomputation", + )); + } + Ok(recomputed) + } +} +/// Verified against exact archive pins in this operation; sources remain caller-owned. +/// This is derived analysis, never accepted-step or model-accuracy qualification. +pub struct VerifiedComparison { + record: ComparisonRecord, +} +impl VerifiedComparison { + pub fn recipe(&self) -> &SavedRecipe { + &self.record.recipe + } + pub fn coverage(&self) -> &Coverage { + &self.record.coverage + } + pub fn sha256(&self) -> Result<[u8; 32]> { + self.record.validate()?; + digest(&self.record) + } + pub fn save_new(&self, path: &Path) -> Result<()> { + self.record.validate()?; + save(path, ArtifactKind::Result, &self.record) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ArtifactKind { + Recipe, + Result, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Envelope { + schema_version: u32, + kind: ArtifactKind, + sha256: [u8; 32], + payload: T, +} +fn save(path: &Path, kind: ArtifactKind, payload: &T) -> Result<()> { + let envelope = Envelope { + schema_version: SCHEMA_VERSION, + kind, + sha256: digest(payload)?, + payload, + }; + publish_new( + path, + &serde_json::to_vec(&envelope).map_err(RunSetError::from)?, + MAX_DOCUMENT_BYTES, + )?; + Ok(()) +} +fn validate_envelope(envelope: &Envelope, kind: ArtifactKind) -> Result<()> { + if envelope.kind != kind { + return Err(ComparisonArchiveError::Invalid("artifact kind")); + } + if envelope.sha256 != digest(&envelope.payload)? { + return Err(ComparisonArchiveError::Integrity("artifact payload digest")); + } + Ok(()) +} +fn digest(value: &T) -> Result<[u8; 32]> { + Ok(Sha256::digest(serde_json::to_vec(value).map_err(RunSetError::from)?).into()) +} +fn analysis_version(version: u32) -> Result<()> { + if version != ANALYSIS_VERSION { + Err(ComparisonArchiveError::Unsupported("analysis version")) + } else { + Ok(()) + } +} +fn member<'a>(set: &'a RunSetRevision, id: &str) -> Result<&'a RunSetMember> { + set.members + .iter() + .find(|m| m.member_id == id) + .ok_or(ComparisonArchiveError::Invalid("comparison member missing")) +} +fn validate_ordinals(ordinals: &[usize]) -> Result<()> { + if ordinals.is_empty() + || ordinals.len() > MAX_SELECTIONS_PER_SOURCE + || ordinals.windows(2).any(|w| w[0] >= w[1]) + { + return Err(ComparisonArchiveError::Invalid( + "ordered bounded selections", + )); + } + Ok(()) +} +fn validate_selected(set: &RunSetRevision, selected: &SelectedMember) -> Result<()> { + let m = member(set, &selected.member_id)?; + let attempt = m + .attempt + .as_ref() + .ok_or(ComparisonArchiveError::Invalid("selected attempt missing"))?; + let pin = attempt + .finalized + .as_ref() + .ok_or(ComparisonArchiveError::Invalid("selected attempt unpinned"))?; + validate_ordinals( + &selected + .selections + .iter() + .map(|s| s.ordinal) + .collect::>(), + )?; + if selected.selections.iter().any(|s| { + s.ordinal >= pin.recorded_count + || s.run_id != attempt.expected_run_id + || s.source_revision != attempt.expected_source_revision + }) { + return Err(ComparisonArchiveError::Invalid( + "selected publication binding", + )); + } + Ok(()) +} +fn open_source( + root: &Path, + m: &RunSetMember, + side: Side, +) -> Result { + let problem = match open_member(root, m) { + MemberState::Ready(reader) => return Ok(*reader), + MemberState::Missing => SourceProblem::Missing, + MemberState::Incomplete { .. } => SourceProblem::Incomplete, + MemberState::Unpinned { .. } => SourceProblem::Unpinned, + MemberState::NotRun => SourceProblem::NotRun, + MemberState::Unsupported(r) => SourceProblem::Unsupported(r), + MemberState::Invalid(e) => SourceProblem::Invalid(e), + }; + Err(ComparisonArchiveError::Source { side, problem }) +} +fn select( + reader: &rheon::recording::RecordingReader, + ordinal: usize, + side: Side, + selected_bytes: &mut usize, +) -> Result { + let selected = + Selection::read(reader, ordinal).map_err(|e| ComparisonArchiveError::Source { + side, + problem: SourceProblem::Invalid(RunSetError::Recording(e)), + })?; + let bytes = selected + .publication() + .fields + .iter() + .try_fold(0usize, |total, f| total.checked_add(f.data.len())) + .ok_or(ComparisonArchiveError::Invalid("selected field bytes"))?; + *selected_bytes = selected_bytes + .checked_add(bytes) + .ok_or(ComparisonArchiveError::Invalid("selected field bytes"))?; + if *selected_bytes > MAX_SELECTED_FIELD_BYTES { + return Err(ComparisonArchiveError::Invalid( + "selected field byte budget", + )); + } + Ok(selected) +} +fn capture_member( + root: &Path, + set: &RunSetRevision, + id: &str, + ordinals: &[usize], + side: Side, + selected_bytes: &mut usize, +) -> Result { + validate_ordinals(ordinals)?; + let reader = open_source(root, member(set, id)?, side)?; + let mut selections = Vec::new(); + for ordinal in ordinals { + selections.push( + select(&reader, *ordinal, side, selected_bytes)? + .identity() + .clone(), + ); + } + Ok(SelectedMember { + member_id: id.into(), + selections, + }) +} +fn load_selected( + root: &Path, + set: &RunSetRevision, + pin: &SelectedMember, + side: Side, + selected_bytes: &mut usize, +) -> Result> { + let reader = open_source(root, member(set, &pin.member_id)?, side)?; + let mut selections = Vec::new(); + for expected in &pin.selections { + let actual = select(&reader, expected.ordinal, side, selected_bytes)?; + if actual.identity() != expected { + return Err(ComparisonArchiveError::Source { + side, + problem: SourceProblem::SelectionChanged, + }); + } + selections.push(actual); + } + Ok(selections) +} +fn validate_field(field: &FieldComparison, recipe: Recipe) -> Result<()> { + let m = field.metrics; + if m.support_count == 0 + || [m.l2, m.rmse, m.linf] + .iter() + .any(|v| !v.is_finite() || *v < 0.0) + || m.relative_l2.is_some_and(|v| !v.is_finite() || v < 0.0) + || !field.color_bounds.min.is_finite() + || !field.color_bounds.max.is_finite() + || field.color_bounds.min > field.color_bounds.max + { + return Err(ComparisonArchiveError::Invalid( + "finite result metrics and bounds", + )); + } + let expected_units = match recipe.field { + FieldRole::Tracer => "1", + FieldRole::IntervalPressure => "Pa", + FieldRole::VelocityDivergence => "1/s", + _ => "m/s", + }; + if field.units != expected_units { + return Err(ComparisonArchiveError::Invalid("result units")); + } + if recipe.field == FieldRole::IntervalPressure { + let gauge = field + .gauge + .ok_or(ComparisonArchiveError::Invalid("pressure gauge report"))?; + if !gauge.removed_offset_pa.is_finite() + || gauge.diagnostic != (recipe.pressure == PressurePolicy::RemoveMeanOffsetDiagnostic) + || (!gauge.diagnostic + && (!gauge.reference_canonical + || !gauge.candidate_canonical + || gauge.removed_offset_pa != 0.0)) + { + return Err(ComparisonArchiveError::Invalid("pressure gauge report")); + } + } else if field.gauge.is_some() { + return Err(ComparisonArchiveError::Invalid("nonpressure gauge report")); + } + Ok(()) +} diff --git a/src/bin/desktop/run_sets.rs b/src/bin/desktop/run_sets.rs new file mode 100644 index 00000000..a90c5efb --- /dev/null +++ b/src/bin/desktop/run_sets.rs @@ -0,0 +1,851 @@ +//! Immutable application intent and bounded manifests over existing numerical archives. +//! Native sealed-box carrier v1 only. This module never runs a worker or model, +//! parses Debug provenance, appends numerical data, or restores integrator state. +//! Opening inherits RecordingReader's SQLite hot-journal recovery contract. +use rheon::{ + GridGeometry, PressureImplementation, PressureSettings, SessionIdentity, Simulation, + SimulationConfig, SmokeSource, SnapshotStage, + recording::{ + RecordedCarrier, RecordingError, RecordingLimits, RecordingReader, RecordingRun, + RecordingStatus, carrier_run, + }, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest, Sha256}; +use std::{ + fmt, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Component, Path}, + sync::atomic::{AtomicU64, Ordering}, +}; + +const MANIFEST_VERSION: u32 = 1; +const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; +const MAX_MEMBERS: usize = 64; +const MAX_TEXT_BYTES: usize = 1000; + +#[derive(Debug)] +pub enum RunSetError { + Io(std::io::Error), + Codec(serde_json::Error), + Invalid(&'static str), + Unsupported(&'static str), + Integrity(&'static str), + Recording(RecordingError), + /// Publication may already be visible. Preserve it; reopen to reconcile. + PublicationUncertain(std::io::Error), +} +impl fmt::Display for RunSetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "saved run set I/O: {error}"), + Self::Codec(error) => write!(f, "saved run set codec: {error}"), + Self::Invalid(reason) => write!(f, "invalid saved run set: {reason}"), + Self::Unsupported(reason) => write!(f, "unsupported saved run set: {reason}"), + Self::Integrity(reason) => write!(f, "saved run set integrity: {reason}"), + Self::Recording(error) => write!(f, "saved run set archive: {error}"), + Self::PublicationUncertain(error) => { + write!(f, "saved run set publication uncertain: {error}") + } + } + } +} +impl std::error::Error for RunSetError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) | Self::PublicationUncertain(error) => Some(error), + Self::Codec(error) => Some(error), + Self::Recording(error) => Some(error), + _ => None, + } + } +} +impl From for RunSetError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} +impl From for RunSetError { + fn from(value: serde_json::Error) -> Self { + Self::Codec(value) + } +} +impl From for RunSetError { + fn from(value: RecordingError) -> Self { + Self::Recording(value) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NativePressureMethod { + JacobiPcgV1, + SymmetricGaussSeidelPcgV1, +} +impl NativePressureMethod { + pub fn implementation(self) -> PressureImplementation { + match self { + Self::JacobiPcgV1 => PressureImplementation::JacobiPcgV1, + Self::SymmetricGaussSeidelPcgV1 => PressureImplementation::SymmetricGaussSeidelPcgV1, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SourceSpec { + pub lower_m: [f64; 3], + pub upper_m: [f64; 3], + pub tracer_rate_per_s: f64, + pub vertical_acceleration_m_s2: f64, + /// Source is applied for exactly this many requested intervals from zero. + pub active_steps: usize, +} +impl SourceSpec { + fn source(&self) -> SmokeSource { + SmokeSource { + lower: self.lower_m, + upper: self.upper_m, + tracer_rate: self.tracer_rate_per_s, + vertical_acceleration: self.vertical_acceleration_m_s2, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct NativeTolerances { + pub relative_residual: f64, + pub absolute_residual_m3_s2: f64, + pub predicted_divergence_per_s: f64, + pub actual_divergence_per_s: f64, + pub max_courant: f64, + pub max_pressure_iterations: usize, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ArchiveBudget { + pub max_database_bytes: u64, + pub max_record_bytes: usize, + pub max_records: usize, +} +impl ArchiveBudget { + pub fn limits(self) -> RecordingLimits { + RecordingLimits { + max_database_bytes: self.max_database_bytes, + max_record_bytes: self.max_record_bytes, + max_records: self.max_records, + } + } +} + +/// Runnable constructor-zero, sealed-box native carrier intent. No external +/// initial assets, viscosity, obstacle, adaptive dt, emitter or provider implied. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct NativeCarrierSpec { + pub counts_xyz: [u64; 3], + pub spacing_m: [f64; 3], + pub origin_m: [f64; 3], + pub density_kg_m3: f64, + pub pressure_method: NativePressureMethod, + pub tolerances: NativeTolerances, + pub requested_dt_s: f64, + pub requested_steps: usize, + pub source: Option, + pub simulation_memory_limit_bytes: usize, + pub recording: ArchiveBudget, +} +impl NativeCarrierSpec { + pub fn validate(&self) -> Result<(), RunSetError> { + let grid = GridGeometry::new(self.counts_xyz, self.spacing_m, self.origin_m) + .map_err(|_| RunSetError::Invalid("spec geometry"))?; + self.recording.limits().validate()?; + let t = &self.tolerances; + if !self.density_kg_m3.is_finite() + || self.density_kg_m3 <= 0.0 + || !self.requested_dt_s.is_finite() + || self.requested_dt_s <= 0.0 + || self.requested_steps == 0 + || self.requested_steps > 10000 + || !(self.requested_dt_s * self.requested_steps as f64).is_finite() + || self.simulation_memory_limit_bytes == 0 + || [ + t.relative_residual, + t.absolute_residual_m3_s2, + t.predicted_divergence_per_s, + t.actual_divergence_per_s, + ] + .iter() + .any(|x| !x.is_finite() || *x < 0.0) + || !t.max_courant.is_finite() + || t.max_courant <= 0.0 + || t.max_courant > 1.0 + || t.max_pressure_iterations == 0 + { + return Err(RunSetError::Invalid("spec controls")); + } + if let Some(s) = &self.source + && (s.active_steps > self.requested_steps + || !s.tracer_rate_per_s.is_finite() + || s.tracer_rate_per_s < 0.0 + || !s.vertical_acceleration_m_s2.is_finite() + || (0..3).any(|i| { + !s.lower_m[i].is_finite() + || !s.upper_m[i].is_finite() + || s.lower_m[i] >= s.upper_m[i] + || s.lower_m[i] < grid.origin()[i] + || s.upper_m[i] > grid.upper()[i] + })) + { + return Err(RunSetError::Invalid("spec source")); + } + Ok(()) + } + pub fn simulation_config(&self) -> SimulationConfig { + let t = &self.tolerances; + SimulationConfig { + density: self.density_kg_m3, + memory_limit: self.simulation_memory_limit_bytes, + pressure: PressureSettings { + relative_residual: t.relative_residual, + absolute_residual: t.absolute_residual_m3_s2, + divergence_limit: t.predicted_divergence_per_s, + max_iterations: t.max_pressure_iterations, + }, + actual_divergence_limit: t.actual_divergence_per_s, + max_courant: t.max_courant, + } + } + /// Existing numerical owners make final allocation and physics admission. + pub fn create_simulation(&self) -> Result { + self.validate()?; + let grid = GridGeometry::new(self.counts_xyz, self.spacing_m, self.origin_m) + .map_err(|_| RunSetError::Invalid("spec geometry"))?; + Simulation::with_implementation( + grid, + self.simulation_config(), + self.pressure_method.implementation(), + ) + .map_err(|_| RunSetError::Invalid("numerical constructor rejected spec")) + } + /// Zero-based requested interval index; history is owned by Simulation. + pub fn source_at(&self, interval: usize) -> Option { + self.source + .as_ref() + .filter(|s| interval < s.active_steps) + .map(SourceSpec::source) + } +} + +/// Digest binds a validated typed payload, never a mutable path or Debug text. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct FrozenSpec { + pub schema_version: u32, + pub sha256: [u8; 32], + pub spec: NativeCarrierSpec, +} +impl FrozenSpec { + /// Freeze the existing native producer's actual constructor configuration. + /// The worker retains its loop and supplies only its explicit future time + /// and source schedule; accepted state is never interpreted as a checkpoint. + pub fn capture_constructor( + simulation: &Simulation, + requested_dt_s: f64, + requested_steps: usize, + source: Option, + recording: RecordingLimits, + ) -> Result { + if simulation.state().generation != 0 || simulation.state().time != 0.0 { + return Err(RunSetError::Invalid("native intent requires constructor")); + } + let actual = simulation.config(); + let pressure_method = match simulation.implementation() { + PressureImplementation::JacobiPcgV1 => NativePressureMethod::JacobiPcgV1, + PressureImplementation::SymmetricGaussSeidelPcgV1 => { + NativePressureMethod::SymmetricGaussSeidelPcgV1 + } + }; + Self::new(NativeCarrierSpec { + counts_xyz: simulation.grid().counts().map(|value| value as u64), + spacing_m: simulation.grid().spacing(), + origin_m: simulation.grid().origin(), + density_kg_m3: actual.density, + pressure_method, + tolerances: NativeTolerances { + relative_residual: actual.pressure.relative_residual, + absolute_residual_m3_s2: actual.pressure.absolute_residual, + predicted_divergence_per_s: actual.pressure.divergence_limit, + actual_divergence_per_s: actual.actual_divergence_limit, + max_courant: actual.max_courant, + max_pressure_iterations: actual.pressure.max_iterations, + }, + requested_dt_s, + requested_steps, + source, + simulation_memory_limit_bytes: actual.memory_limit, + recording: ArchiveBudget { + max_database_bytes: recording.max_database_bytes, + max_record_bytes: recording.max_record_bytes, + max_records: recording.max_records, + }, + }) + } + pub fn new(spec: NativeCarrierSpec) -> Result { + spec.validate()?; + Ok(Self { + schema_version: MANIFEST_VERSION, + sha256: digest(&spec)?, + spec, + }) + } + pub fn validate(&self) -> Result<(), RunSetError> { + version(self.schema_version)?; + self.spec.validate()?; + if self.sha256 != digest(&self.spec)? { + return Err(RunSetError::Integrity("spec digest")); + } + Ok(()) + } + pub fn save_new(&self, path: &Path) -> Result<(), RunSetError> { + self.validate()?; + publish_new(path, &serde_json::to_vec(self)?, MAX_MANIFEST_BYTES) + } + pub fn open(path: &Path) -> Result { + let result: Self = read_versioned(path, MANIFEST_VERSION, MAX_MANIFEST_BYTES)?; + result.validate()?; + Ok(result) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct NativeIntentDeclaration { + schema_version: u32, + spec_sha256: [u8; 32], + build_provenance: String, +} +/// Attach the complete immutable intent identity to the existing archive's +/// provenance, after checking actual constructor configuration. This is a +/// producer declaration, not authentication or proof of an external execution. +/// The composer must drive dt and source schedules from this same frozen spec. +pub fn bound_native_run( + spec: &FrozenSpec, + simulation: &Simulation, + identity: SessionIdentity, + build_provenance: &str, +) -> Result { + spec.validate()?; + text(build_provenance)?; + let actual = simulation.config(); + let mut observed = spec.spec.clone(); + observed.counts_xyz = simulation.grid().counts().map(|n| n as u64); + observed.spacing_m = simulation.grid().spacing(); + observed.origin_m = simulation.grid().origin(); + observed.density_kg_m3 = actual.density; + observed.simulation_memory_limit_bytes = actual.memory_limit; + observed.tolerances = NativeTolerances { + relative_residual: actual.pressure.relative_residual, + absolute_residual_m3_s2: actual.pressure.absolute_residual, + predicted_divergence_per_s: actual.pressure.divergence_limit, + actual_divergence_per_s: actual.actual_divergence_limit, + max_courant: actual.max_courant, + max_pressure_iterations: actual.pressure.max_iterations, + }; + if observed != spec.spec + || simulation.implementation() != spec.spec.pressure_method.implementation() + || simulation.state().generation != 0 + || simulation.state().time != 0.0 + { + return Err(RunSetError::Invalid("native constructor/spec binding")); + } + let provenance = serde_json::to_string(&NativeIntentDeclaration { + schema_version: MANIFEST_VERSION, + spec_sha256: spec.sha256, + build_provenance: build_provenance.into(), + })?; + Ok(carrier_run(identity, simulation, &provenance)?) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TerminalOutcome { + Completed, + Cancelled, + Failed, +} +impl TerminalOutcome { + fn from_status(status: &str) -> Result { + match status { + "completed" => Ok(Self::Completed), + "cancelled" => Ok(Self::Cancelled), + "failed" => Ok(Self::Failed), + _ => Err(RunSetError::Invalid("archive is not terminal")), + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct FinalizedArchive { + pub sha256: [u8; 32], + pub recorded_count: usize, + pub outcome: TerminalOutcome, + pub accepted_generation: Option, +} +/// A retry is a new attempt ID and fresh archive. A reference with no final pin +/// is inspectable as missing/incomplete/unpinned, never silently promoted ready. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RunAttempt { + pub attempt_id: String, + pub archive_relative_path: String, + pub expected_run_id: u64, + pub expected_source_revision: String, + pub finalized: Option, +} +impl RunAttempt { + fn validate(&self) -> Result<(), RunSetError> { + text(&self.attempt_id)?; + text(&self.expected_source_revision)?; + relative_path(&self.archive_relative_path)?; + Ok(()) + } + /// Call only after closing the producer/writer. Local protected paths and + /// immutable terminal artifacts are required throughout capture and reopen. + pub fn pin_closed(&mut self, root: &Path, spec: &FrozenSpec) -> Result<(), RunSetError> { + self.validate()?; + spec.validate()?; + if self.finalized.is_some() { + return Err(RunSetError::Invalid("attempt already pinned")); + } + let path = archive_path(root, &self.archive_relative_path)?; + let reader = RecordingReader::open(&path, spec.spec.recording.limits())?; + validate_binding(&reader, self, spec)?; + let status = reader.status()?; + let outcome = TerminalOutcome::from_status(&status.outcome)?; + reader.verify()?; + validate_native_publications(&reader, &spec.spec, outcome)?; + let recorded_count = reader.count()?; + drop(reader); + self.finalized = Some(FinalizedArchive { + sha256: file_digest(&path, spec.spec.recording.max_database_bytes)?, + recorded_count, + outcome, + accepted_generation: status.accepted_generation, + }); + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SpecDifference { + Geometry, + Density, + PressureMethod, + Tolerances, + TimePolicy, + Source, + MemoryBudget, + RecordingBudget, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RunSetMember { + pub member_id: String, + pub spec: FrozenSpec, + pub differences_from_baseline: Vec, + pub attempt: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RunSetRevision { + pub schema_version: u32, + pub set_id: String, + /// Optional exact parent content identity; there is no mutable latest alias. + pub parent_sha256: Option<[u8; 32]>, + pub baseline_member: String, + pub members: Vec, +} +impl RunSetRevision { + pub fn validate(&self) -> Result<(), RunSetError> { + version(self.schema_version)?; + text(&self.set_id)?; + text(&self.baseline_member)?; + if self.members.is_empty() || self.members.len() > MAX_MEMBERS { + return Err(RunSetError::Invalid("member count")); + } + let baseline = self + .members + .iter() + .find(|m| m.member_id == self.baseline_member) + .ok_or(RunSetError::Invalid("baseline missing"))?; + for (i, m) in self.members.iter().enumerate() { + text(&m.member_id)?; + m.spec.validate()?; + if self.members[..i] + .iter() + .any(|other| other.member_id == m.member_id) + { + return Err(RunSetError::Invalid("duplicate member")); + } + if differences(&baseline.spec.spec, &m.spec.spec) != m.differences_from_baseline { + return Err(RunSetError::Invalid("declared differences")); + } + if let Some(a) = &m.attempt { + a.validate()?; + if self.members[..i] + .iter() + .filter_map(|m| m.attempt.as_ref()) + .any(|other| { + other.attempt_id == a.attempt_id + || other.archive_relative_path == a.archive_relative_path + }) + { + return Err(RunSetError::Invalid("duplicate attempt or archive")); + } + } + } + Ok(()) + } + pub fn sha256(&self) -> Result<[u8; 32], RunSetError> { + self.validate()?; + digest(self) + } + pub fn save_new(&self, path: &Path) -> Result<(), RunSetError> { + self.validate()?; + let envelope = Manifest { + schema_version: MANIFEST_VERSION, + sha256: digest(self)?, + revision: self.clone(), + }; + publish_new(path, &serde_json::to_vec(&envelope)?, MAX_MANIFEST_BYTES) + } + pub fn open(path: &Path) -> Result { + let envelope: Manifest = read_versioned(path, MANIFEST_VERSION, MAX_MANIFEST_BYTES)?; + envelope.revision.validate()?; + if envelope.sha256 != digest(&envelope.revision)? { + return Err(RunSetError::Integrity("run-set digest")); + } + Ok(envelope.revision) + } +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + schema_version: u32, + sha256: [u8; 32], + revision: RunSetRevision, +} + +/// No numerical data is returned for a missing/unpinned/incomplete member. +pub enum MemberState { + NotRun, + Missing, + Incomplete { + recorded_count: usize, + status: RecordingStatus, + }, + Unpinned { + recorded_count: usize, + status: RecordingStatus, + }, + Unsupported(&'static str), + Invalid(RunSetError), + Ready(Box), +} +/// Explicit resolution from this immutable member only. No live/latest lookup, +/// model loading, missing-field substitution, migration, deletion or execution. +pub fn open_member(root: &Path, member: &RunSetMember) -> MemberState { + match open_member_inner(root, member) { + Ok(state) => state, + Err(RunSetError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => MemberState::Missing, + Err(RunSetError::Recording(RecordingError::Io(e))) + if e.kind() == std::io::ErrorKind::NotFound => + { + MemberState::Missing + } + Err( + RunSetError::Unsupported(reason) + | RunSetError::Recording(RecordingError::Unsupported(reason)), + ) => MemberState::Unsupported(reason), + Err(error) => MemberState::Invalid(error), + } +} +fn open_member_inner(root: &Path, m: &RunSetMember) -> Result { + m.spec.validate()?; + let Some(a) = &m.attempt else { + return Ok(MemberState::NotRun); + }; + a.validate()?; + let path = archive_path(root, &a.archive_relative_path)?; + let reader = RecordingReader::open(&path, m.spec.spec.recording.limits())?; + validate_binding(&reader, a, &m.spec)?; + let status = reader.status()?; + let count = reader.count()?; + let Some(pin) = &a.finalized else { + return Ok(if status.outcome == "active" { + MemberState::Incomplete { + recorded_count: count, + status, + } + } else { + MemberState::Unpinned { + recorded_count: count, + status, + } + }); + }; + if status.outcome == "active" { + return Err(RunSetError::Integrity("pinned archive became active")); + } + if pin.recorded_count != count + || pin.outcome != TerminalOutcome::from_status(&status.outcome)? + || pin.accepted_generation != status.accepted_generation + || pin.sha256 != file_digest(&path, m.spec.spec.recording.max_database_bytes)? + { + return Err(RunSetError::Integrity("finalized archive changed")); + } + reader.verify()?; + validate_native_publications(&reader, &m.spec.spec, pin.outcome)?; + Ok(MemberState::Ready(Box::new(reader))) +} +fn validate_binding( + reader: &RecordingReader, + a: &RunAttempt, + spec: &FrozenSpec, +) -> Result<(), RunSetError> { + let s = &spec.spec; + let run = reader.run(); + let actual_limits = reader.recorded_limits(); + let intended_limits = s.recording.limits(); + if actual_limits != intended_limits { + return Err(RunSetError::Integrity("archive recording budget")); + } + if !run.solver.starts_with("rheon-carrier-full-box-v1/") { + return Err(RunSetError::Unsupported("saved native carrier profile")); + } + let declaration: NativeIntentDeclaration = serde_json::from_str(&run.provenance)?; + version(declaration.schema_version)?; + text(&declaration.build_provenance)?; + if run.identity.run_id != a.expected_run_id + || run.identity.source_revision != a.expected_source_revision + || run.counts != s.counts_xyz + || run.spacing.map(f64::to_bits) != s.spacing_m.map(f64::to_bits) + || run.origin.map(f64::to_bits) != s.origin_m.map(f64::to_bits) + || run.solver.rsplit('/').next() != Some(s.pressure_method.implementation().id()) + || declaration.spec_sha256 != spec.sha256 + { + return Err(RunSetError::Integrity("archive/spec binding")); + } + Ok(()) +} +fn validate_native_publications( + reader: &RecordingReader, + spec: &NativeCarrierSpec, + outcome: TerminalOutcome, +) -> Result<(), RunSetError> { + let count = reader.count()?; + if count > spec.requested_steps + 1 + || reader + .status()? + .accepted_generation + .is_some_and(|generation| generation > spec.requested_steps as u64) + { + return Err(RunSetError::Integrity("native publication horizon")); + } + for ordinal in 0..count { + let step = reader.select(ordinal)?; + RecordedCarrier::decode(reader.run(), &step)?; + if step.generation != ordinal as u64 + || (ordinal == 0 && step.stage != SnapshotStage::Constructor) + || (ordinal > 0 + && (step.stage != SnapshotStage::Accepted + || step.dt.map(f64::to_bits) != Some(spec.requested_dt_s.to_bits()))) + { + return Err(RunSetError::Integrity("native publication schedule")); + } + } + if outcome == TerminalOutcome::Completed && count != spec.requested_steps + 1 { + return Err(RunSetError::Integrity("completed native horizon")); + } + Ok(()) +} +fn differences(a: &NativeCarrierSpec, b: &NativeCarrierSpec) -> Vec { + use SpecDifference::*; + let mut result = Vec::new(); + for (different, kind) in [ + ( + a.counts_xyz != b.counts_xyz || a.spacing_m != b.spacing_m || a.origin_m != b.origin_m, + Geometry, + ), + (a.density_kg_m3 != b.density_kg_m3, Density), + (a.pressure_method != b.pressure_method, PressureMethod), + (a.tolerances != b.tolerances, Tolerances), + ( + a.requested_dt_s != b.requested_dt_s || a.requested_steps != b.requested_steps, + TimePolicy, + ), + (a.source != b.source, Source), + ( + a.simulation_memory_limit_bytes != b.simulation_memory_limit_bytes, + MemoryBudget, + ), + (a.recording != b.recording, RecordingBudget), + ] { + if different { + result.push(kind); + } + } + result +} +fn version(value: u32) -> Result<(), RunSetError> { + if value != MANIFEST_VERSION { + Err(RunSetError::Unsupported("manifest version")) + } else { + Ok(()) + } +} +fn text(value: &str) -> Result<(), RunSetError> { + if value.is_empty() || value.len() > MAX_TEXT_BYTES || value.chars().any(char::is_control) { + Err(RunSetError::Invalid("bounded text")) + } else { + Ok(()) + } +} +pub(crate) fn relative_path(value: &str) -> Result<(), RunSetError> { + text(value)?; + if value + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || value.contains('\\') + || value.contains(':') + || !Path::new(value) + .components() + .all(|c| matches!(c, Component::Normal(_))) + { + return Err(RunSetError::Invalid("archive relative path")); + } + Ok(()) +} +pub(crate) fn archive_path(root: &Path, relative: &str) -> Result { + relative_path(relative)?; + let mut path = root.to_owned(); + for component in Path::new(relative).components() { + path.push(component); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() { + return Err(RunSetError::Invalid("symlink archive component")); + } + } + Ok(path) +} +fn digest(value: &T) -> Result<[u8; 32], RunSetError> { + Ok(Sha256::digest(serde_json::to_vec(value)?).into()) +} +pub(crate) fn file_digest(path: &Path, max_bytes: u64) -> Result<[u8; 32], RunSetError> { + if max_bytes == 0 || max_bytes == u64::MAX { + return Err(RunSetError::Invalid("document byte limit")); + } + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(RunSetError::Invalid("regular file required")); + } + if metadata.len() > max_bytes { + return Err(RunSetError::Invalid("file byte limit")); + } + let mut input = File::open(path)?.take(max_bytes + 1); + let mut buffer = [0; 64 * 1024]; + let mut n = 0u64; + let mut hash = Sha256::new(); + loop { + let read = input.read(&mut buffer)?; + if read == 0 { + break; + } + n += read as u64; + if n > max_bytes { + return Err(RunSetError::Invalid("file byte limit")); + } + hash.update(&buffer[..read]); + } + Ok(hash.finalize().into()) +} +pub(crate) fn read_versioned( + path: &Path, + expected_version: u32, + max_bytes: u64, +) -> Result { + if max_bytes == 0 || max_bytes == u64::MAX { + return Err(RunSetError::Invalid("document byte limit")); + } + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(RunSetError::Invalid("regular manifest required")); + } + if metadata.len() > max_bytes { + return Err(RunSetError::Invalid("manifest byte limit")); + } + let mut bytes = Vec::new(); + File::open(path)? + .take(max_bytes + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > max_bytes { + return Err(RunSetError::Invalid("manifest byte limit")); + } + #[derive(Deserialize)] + struct Header { + schema_version: u64, + } + let header: Header = serde_json::from_slice(&bytes)?; + if header.schema_version != u64::from(expected_version) { + return Err(RunSetError::Unsupported("manifest version")); + } + // Decode the original bytes so duplicate required keys are not erased by a + // generic JSON object representation before strict typed validation. + Ok(serde_json::from_slice(&bytes)?) +} +/// Atomic no-replace publication on caller-owned local filesystems supporting +/// hard links and directory sync. Protect every path component from concurrent +/// replacement throughout use. Existing data and abandoned temporary files are +/// never overwritten as recovery. Interrupted staging is not authoritative. +pub(crate) fn publish_new(path: &Path, bytes: &[u8], max_bytes: u64) -> Result<(), RunSetError> { + if max_bytes == 0 || max_bytes == u64::MAX { + return Err(RunSetError::Invalid("document byte limit")); + } + if bytes.len() as u64 > max_bytes { + return Err(RunSetError::Invalid("manifest byte limit")); + } + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let directory = File::open(parent)?; + static NEXT: AtomicU64 = AtomicU64::new(0); + let temporary = parent.join(format!( + ".rheon-manifest-{}-{}.tmp", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + let staged = file.write_all(bytes).and_then(|()| file.sync_all()); + drop(file); + if let Err(error) = staged { + return Err(RunSetError::Io(error)); + } + fs::hard_link(&temporary, path)?; + // Once linked, errors mean the durable publication outcome needs inspection. + directory + .sync_all() + .map_err(RunSetError::PublicationUncertain)?; + fs::remove_file(&temporary).map_err(RunSetError::PublicationUncertain)?; + directory + .sync_all() + .map_err(RunSetError::PublicationUncertain)?; + Ok(()) +} diff --git a/src/recording/comparison.rs b/src/recording/comparison.rs new file mode 100644 index 00000000..c6b9496b --- /dev/null +++ b/src/recording/comparison.rs @@ -0,0 +1,749 @@ +//! Native, read-only comparison of exact accepted-v1 archive selections. +//! Recipe/version semantics and limitations are documented in +//! `docs/api/recording-comparison.md`. No live simulation or model is consulted. +use crate::recording::{ + RecordedField, RecordedStep, RecordingError, RecordingReader, RecordingRun, +}; +use crate::{PressureImplementation, SnapshotStage}; +use sha2::{Digest, Sha256}; + +/// Version of alignment, gauge and reduction semantics. +pub const ANALYSIS_VERSION: u32 = 1; + +const CELL: &str = "cell_center; offset [0.5,0.5,0.5]; Cartesian XYZ; x-fast"; +const FACES: [&str; 3] = [ + "x_face; offset [0,0.5,0.5]; Cartesian XYZ; x-fast", + "y_face; offset [0.5,0,0.5]; Cartesian XYZ; x-fast", + "z_face; offset [0.5,0.5,0]; Cartesian XYZ; x-fast", +]; +const PRESSURE_STAGE: &str = "accepted_interval_projection; cell0 gauge=0; not endpoint pressure"; + +/// Identity of one exact publication, stable when its archive is appended or closed. +/// The digest is analysis-v1 SHA256, not an archive-file hash or authentication. +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionIdentity { + pub run_id: u64, + pub source_revision: String, + pub ordinal: usize, + pub generation: u64, + pub sha256: [u8; 32], +} + +/// Immutable owned, bounded reader selection. Construction verifies archive hashes. +pub struct Selection { + run: RecordingRun, + step: RecordedStep, + identity: SelectionIdentity, +} +impl Selection { + pub fn read(reader: &RecordingReader, ordinal: usize) -> Result { + let step = reader.select(ordinal)?; + let run = reader.run().clone(); + let identity = SelectionIdentity { + run_id: run.identity.run_id, + source_revision: run.identity.source_revision.clone(), + ordinal, + generation: step.generation, + sha256: selection_digest(&run, &step), + }; + Ok(Self { + run, + step, + identity, + }) + } + pub fn identity(&self) -> &SelectionIdentity { + &self.identity + } + pub fn digest(&self) -> [u8; 32] { + self.identity.sha256 + } + /// Run metadata identity; excludes lifecycle and storage budgets. + pub fn run_digest(&self) -> [u8; 32] { + run_digest(&self.run) + } + pub fn publication(&self) -> &RecordedStep { + &self.step + } + pub fn run(&self) -> &RecordingRun { + &self.run + } +} + +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(rename_all = "snake_case"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FieldRole { + Tracer, + IntervalPressure, + VelocityX, + VelocityY, + VelocityZ, + VelocityDivergence, +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(rename_all = "snake_case"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PressurePolicy { + /// Both fields must actually have cell0=0; no transform is applied. + Canonical, + /// Diagnostic only: subtract mean(candidate-reference), retaining gauge failures. + RemoveMeanOffsetDiagnostic, +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Recipe { + pub field: FieldRole, + /// Absolute physical-time tolerance, seconds; finite and nonnegative. + pub time_tolerance: f64, + pub pressure: PressurePolicy, +} +impl Recipe { + pub fn validate(self) -> Result<(), RecipeError> { + validate_recipe(self) + } +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(rename_all = "snake_case"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Unavailable { + UnsupportedProfile, + Geometry, + PublicationStage, + PhysicalTime, + MissingField, + FieldSchema, + NoncanonicalGauge, + EmptySupport, + ArithmeticRange, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecipeError { + InvalidTolerance, + AmbiguousAlignment, +} +impl std::fmt::Display for RecipeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "comparison recipe: {self:?}") + } +} +impl std::error::Error for RecipeError {} + +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Metrics { + pub support_count: usize, + /// Euclidean norm of candidate-reference in field units. + pub l2: f64, + /// Uniform native-volume-weighted root mean square in field units. + pub rmse: f64, + pub linf: f64, + /// None precisely when the reference norm is zero; no epsilon denominator. + #[cfg_attr( + feature = "desktop", + serde(deserialize_with = "deserialize_explicit_option") + )] + pub relative_l2: Option, +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ColorBounds { + pub min: f64, + pub max: f64, +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GaugeReport { + pub reference_canonical: bool, + pub candidate_canonical: bool, + /// Mean(candidate-reference), subtracted only in the diagnostic policy. + pub removed_offset_pa: f64, + pub diagnostic: bool, +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, PartialEq)] +pub struct FieldComparison { + pub metrics: Metrics, + pub units: &'static str, + /// Common bounds of the two compared fields after the declared transform. + pub color_bounds: ColorBounds, + pub gauge: Option, +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, PartialEq)] +pub struct PairComparison { + pub analysis_version: u32, + pub recipe: Recipe, + pub reference: SelectionIdentity, + pub candidate: SelectionIdentity, + pub outcome: Result, +} +#[cfg_attr(feature = "desktop", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "desktop", serde(deny_unknown_fields))] +#[derive(Debug, Clone, PartialEq)] +pub struct Coverage { + pub analysis_version: u32, + pub recipe: Recipe, + /// Physical-time/stage matches, including unavailable field comparisons. + pub pairs: Vec, + pub reference_unmatched: Vec, + pub candidate_unmatched: Vec, +} + +/// Compare native values; ordinal and run-local generation do not align runs. +pub fn compare( + reference: &Selection, + candidate: &Selection, + recipe: Recipe, +) -> Result { + recipe.validate()?; + Ok(PairComparison { + analysis_version: ANALYSIS_VERSION, + recipe, + reference: reference.identity.clone(), + candidate: candidate.identity.clone(), + outcome: compare_fields(reference, candidate, recipe), + }) +} + +/// Actual native geometry, in metres. Field lattice dimensions are separate. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NativeGeometry { + pub counts_xyz: [u64; 3], + pub spacing_m: [f64; 3], + pub origin_m: [f64; 3], +} +/// Native sample locations; face values are never averaged onto cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SampleAssociation { + CellCenter, + XFace, + YFace, + ZFace, +} +/// Cartesian XYZ, x-fast scalar samples. Offsets are measured in cells from origin. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NativeSampling { + pub association: SampleAssociation, + pub dimensions: [u64; 3], + pub offset_cells: [f64; 3], +} +/// Owned presentation values and the exact reduction that authorized them. +/// Pressure candidates include only the summary's declared gauge transform. +/// This is transient derived data, not accepted state or a persisted field store. +#[derive(Debug, Clone, PartialEq)] +pub struct ComparedValues { + pub reference: Vec, + pub candidate: Vec, + pub comparison: PairComparison, + pub geometry: NativeGeometry, + pub sampling: NativeSampling, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComparedValuesError { + Recipe(RecipeError), + Unavailable(Unavailable), +} +impl std::fmt::Display for ComparedValuesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Recipe(e) => write!(f, "native comparison values: {e}"), + Self::Unavailable(reason) => { + write!(f, "native comparison values unavailable: {reason:?}") + } + } + } +} +impl std::error::Error for ComparedValuesError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Recipe(e) => Some(e), + Self::Unavailable(_) => None, + } + } +} +impl From for ComparedValuesError { + fn from(value: Unavailable) -> Self { + Self::Unavailable(value) + } +} + +/// Synchronously materialize one already bounded immutable native pair. +/// Reuses the comparison/field validators and exact returned gauge offset. The +/// extra extraction intentionally avoids a second mean or reduction algorithm. +/// Recording limits bound source records, not peak RSS or promoted f64 allocations. +pub fn compared_values( + reference: &Selection, + candidate: &Selection, + recipe: Recipe, +) -> Result { + let comparison = compare(reference, candidate, recipe).map_err(ComparedValuesError::Recipe)?; + let summary = comparison + .outcome + .as_ref() + .map_err(|reason| ComparedValuesError::Unavailable(*reason))?; + let x = values(reference, recipe.field)?; + let mut y = values(candidate, recipe.field)?; + if let Some(gauge) = summary.gauge { + for value in &mut y { + *value -= gauge.removed_offset_pa; + } + } + if x.iter().chain(&y).any(|value| !value.is_finite()) { + return Err(Unavailable::ArithmeticRange.into()); + } + let mut dimensions = reference.run.counts; + let mut offset_cells = [0.5; 3]; + let association = if let Some(axis) = face_axis(recipe.field) { + dimensions[axis] = dimensions[axis] + .checked_add(1) + .ok_or(Unavailable::FieldSchema)?; + offset_cells[axis] = 0.0; + [ + SampleAssociation::XFace, + SampleAssociation::YFace, + SampleAssociation::ZFace, + ][axis] + } else { + SampleAssociation::CellCenter + }; + Ok(ComparedValues { + reference: x, + candidate: y, + comparison, + geometry: NativeGeometry { + counts_xyz: reference.run.counts, + spacing_m: reference.run.spacing, + origin_m: reference.run.origin, + }, + sampling: NativeSampling { + association, + dimensions, + offset_cells, + }, + }) +} + +/// One-to-one alignment of caller-bounded selections. Multiple matches within +/// tolerance reject as ambiguous; input order never chooses a scientific match. +/// No interpolation, remapping or cross-run particle pairing is performed. +pub fn align( + reference: &[Selection], + candidate: &[Selection], + recipe: Recipe, +) -> Result { + recipe.validate()?; + let mut result = Coverage { + analysis_version: ANALYSIS_VERSION, + recipe, + pairs: Vec::new(), + reference_unmatched: Vec::new(), + candidate_unmatched: Vec::new(), + }; + let mut used = vec![false; candidate.len()]; + for a in reference { + let mut match_index = None; + for (i, b) in candidate.iter().enumerate() { + if temporal_compatibility(a, b, recipe).is_ok() { + if match_index.is_some() || used[i] { + return Err(RecipeError::AmbiguousAlignment); + } + match_index = Some(i); + } + } + if let Some(i) = match_index { + used[i] = true; + result.pairs.push(compare(a, &candidate[i], recipe)?); + } else { + result.reference_unmatched.push(a.identity.clone()); + } + } + for (b, used) in candidate.iter().zip(used) { + if !used { + result.candidate_unmatched.push(b.identity.clone()); + } + } + Ok(result) +} +fn validate_recipe(recipe: Recipe) -> Result<(), RecipeError> { + if recipe.time_tolerance.is_finite() && recipe.time_tolerance >= 0.0 { + Ok(()) + } else { + Err(RecipeError::InvalidTolerance) + } +} +fn temporal_compatibility(a: &Selection, b: &Selection, recipe: Recipe) -> Result<(), Unavailable> { + if a.step.stage != b.step.stage { + return Err(Unavailable::PublicationStage); + } + if (a.step.time - b.step.time).abs() > recipe.time_tolerance { + return Err(Unavailable::PhysicalTime); + } + if recipe.field == FieldRole::IntervalPressure { + let start = |s: &Selection| { + let dt = s.step.dt?; + // Invalid/missing fields cannot erase a temporal match. The step's + // interval remains authoritative for pairing; values() rejects the field. + s.step + .field("interval_pressure") + .and_then(|f| f.evaluation_time) + .filter(|start| { + *start < s.step.time + && (s.step.time - start - dt).abs() + <= 8.0 * f64::EPSILON * s.step.time.max(dt) + }) + .or(Some(s.step.time - dt)) + }; + match (start(a), start(b)) { + (Some(x), Some(y)) if (x - y).abs() <= recipe.time_tolerance => {} + (None, None) => {} + _ => return Err(Unavailable::PhysicalTime), + } + } + Ok(()) +} +fn compare_fields( + a: &Selection, + b: &Selection, + recipe: Recipe, +) -> Result { + for s in [a, b] { + if !s.run.solver.starts_with("rheon-carrier-full-box-v1/") + || s.run + .solver + .rsplit('/') + .next() + .and_then(PressureImplementation::from_id) + .is_none() + { + return Err(Unavailable::UnsupportedProfile); + } + } + if a.run.counts != b.run.counts + || !same_bits(a.run.spacing, b.run.spacing) + || !same_bits(a.run.origin, b.run.origin) + { + return Err(Unavailable::Geometry); + } + temporal_compatibility(a, b, recipe)?; + let mut x = values(a, recipe.field)?; + let mut y = values(b, recipe.field)?; + let gauge = if recipe.field == FieldRole::IntervalPressure { + let reference_canonical = x.first() == Some(&0.0); + let candidate_canonical = y.first() == Some(&0.0); + if recipe.pressure == PressurePolicy::Canonical + && !(reference_canonical && candidate_canonical) + { + return Err(Unavailable::NoncanonicalGauge); + } + let diagnostic = recipe.pressure == PressurePolicy::RemoveMeanOffsetDiagnostic; + let offset = if diagnostic { + // Scale before summing; compensate rounding in nearly constant offsets. + let n = x.len() as f64; + let mut mean = 0.0; + let mut compensation = 0.0; + for (x, y) in x.iter().zip(&y) { + let term = (y - x) / n - compensation; + let next = mean + term; + compensation = (next - mean) - term; + mean = next; + } + if !mean.is_finite() { + return Err(Unavailable::ArithmeticRange); + } + mean + } else { + 0.0 + }; + for v in &mut y { + *v -= offset; + } + Some(GaugeReport { + reference_canonical, + candidate_canonical, + removed_offset_pa: offset, + diagnostic, + }) + } else { + None + }; + if x.is_empty() { + return Err(Unavailable::EmptySupport); + } + if x.iter().chain(&y).any(|v| !v.is_finite()) { + return Err(Unavailable::ArithmeticRange); + } + let mut norm = 0.0_f64; + let mut error = 0.0_f64; + let mut linf = 0.0_f64; + let mut min = f64::INFINITY; + let mut max = f64::NEG_INFINITY; + for (x, y) in x.drain(..).zip(y) { + let delta = y - x; + norm = norm.hypot(x); + error = error.hypot(delta); + linf = linf.max(delta.abs()); + min = min.min(x).min(y); + max = max.max(x).max(y); + } + // Number of samples follows the exact native schema, not display pixels. + let support_count = sample_count(a, recipe.field)?; + let rmse = error / (support_count as f64).sqrt(); + let relative_l2 = if norm == 0.0 { + None + } else { + Some(error / norm) + }; + if [norm, error, linf, rmse, min, max] + .iter() + .any(|v| !v.is_finite()) + || relative_l2.is_some_and(|v| !v.is_finite()) + { + return Err(Unavailable::ArithmeticRange); + } + Ok(FieldComparison { + metrics: Metrics { + support_count, + l2: error, + rmse, + linf, + relative_l2, + }, + units: match recipe.field { + FieldRole::Tracer => "1", + FieldRole::IntervalPressure => "Pa", + FieldRole::VelocityDivergence => "1/s", + _ => "m/s", + }, + color_bounds: ColorBounds { min, max }, + gauge, + }) +} +fn same_bits(a: [f64; 3], b: [f64; 3]) -> bool { + a.map(f64::to_bits) == b.map(f64::to_bits) +} +fn sample_count(s: &Selection, role: FieldRole) -> Result { + let mut dims = s.run.counts; + if let Some(d) = face_axis(role) { + dims[d] = dims[d].checked_add(1).ok_or(Unavailable::FieldSchema)?; + } + dims.into_iter().try_fold(1usize, |n, d| { + n.checked_mul(usize::try_from(d).map_err(|_| Unavailable::FieldSchema)?) + .ok_or(Unavailable::FieldSchema) + }) +} +fn face_axis(role: FieldRole) -> Option { + match role { + FieldRole::VelocityX => Some(0), + FieldRole::VelocityY => Some(1), + FieldRole::VelocityZ => Some(2), + _ => None, + } +} +fn field_values(field: &RecordedField) -> Result, Unavailable> { + let width = match field.scalar_type.as_str() { + "f32" => 4, + "f64" => 8, + _ => return Err(Unavailable::FieldSchema), + }; + let mut values = Vec::with_capacity(field.data.len() / width); + for b in field.data.chunks_exact(width) { + let value = if width == 4 { + f32::from_le_bytes(b.try_into().unwrap()) as f64 + } else { + f64::from_le_bytes(b.try_into().unwrap()) + }; + if !value.is_finite() { + return Err(Unavailable::FieldSchema); + } + values.push(value); + } + Ok(values) +} +fn values(s: &Selection, role: FieldRole) -> Result, Unavailable> { + if role == FieldRole::VelocityDivergence { + return divergence(s); + } + let face = face_axis(role); + let (name, units, association, kind) = match role { + FieldRole::Tracer => ("tracer", "1", CELL, "f32"), + FieldRole::IntervalPressure => ("interval_pressure", "Pa", CELL, "f64"), + FieldRole::VelocityX => ("velocity_x", "m/s", FACES[0], "f32"), + FieldRole::VelocityY => ("velocity_y", "m/s", FACES[1], "f32"), + FieldRole::VelocityZ => ("velocity_z", "m/s", FACES[2], "f32"), + FieldRole::VelocityDivergence => unreachable!(), + }; + let f = s.step.field(name).ok_or(Unavailable::MissingField)?; + let mut dims = s.run.counts; + if let Some(d) = face { + dims[d] = dims[d].checked_add(1).ok_or(Unavailable::FieldSchema)?; + } + let stage = if role == FieldRole::IntervalPressure { + PRESSURE_STAGE + } else if s.step.stage == SnapshotStage::Constructor { + "constructor" + } else { + "accepted" + }; + if f.units != units + || f.association != association + || f.scalar_type != kind + || f.stage != stage + || f.components != 1 + || f.dimensions != dims + { + return Err(Unavailable::FieldSchema); + } + if role == FieldRole::IntervalPressure { + let (Some(start), Some(dt)) = (f.evaluation_time, s.step.dt) else { + return Err(Unavailable::FieldSchema); + }; + if s.step.stage != SnapshotStage::Accepted + || start >= s.step.time + || (s.step.time - start - dt).abs() > 8.0 * f64::EPSILON * s.step.time.max(dt) + { + return Err(Unavailable::FieldSchema); + } + } else if f.evaluation_time.is_some() { + return Err(Unavailable::FieldSchema); + } + let result = field_values(f)?; + if result.len() != sample_count(s, role)? { + return Err(Unavailable::FieldSchema); + } + Ok(result) +} +fn divergence(s: &Selection) -> Result, Unavailable> { + let x = values(s, FieldRole::VelocityX)?; + let y = values(s, FieldRole::VelocityY)?; + let z = values(s, FieldRole::VelocityZ)?; + let [nx, ny, nz] = s.run.counts.map(|n| usize::try_from(n).unwrap()); + let [dx, dy, dz] = s.run.spacing; + let mut result = Vec::with_capacity(sample_count(s, FieldRole::VelocityDivergence)?); + for k in 0..nz { + for j in 0..ny { + for i in 0..nx { + let ix = i + (nx + 1) * (j + ny * k); + let iy = i + nx * (j + (ny + 1) * k); + let iz = i + nx * (j + ny * k); + result.push( + (x[ix + 1] - x[ix]) / dx + + (y[iy + nx] - y[iy]) / dy + + (z[iz + nx * ny] - z[iz]) / dz, + ); + } + } + } + Ok(result) +} +fn selection_digest(run: &RecordingRun, step: &RecordedStep) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"rheon-native-selection-v1\0"); + h.update(run_digest(run)); + h.update((step.ordinal as u64).to_le_bytes()); + h.update(step.generation.to_le_bytes()); + h.update(step.time.to_le_bytes()); + hash_optional(&mut h, step.dt); + h.update([if step.stage == SnapshotStage::Constructor { + 0 + } else { + 1 + }]); + hash_text(&mut h, &step.controls); + h.update((step.fields.len() as u64).to_le_bytes()); + for f in &step.fields { + for t in [&f.name, &f.units, &f.association, &f.stage, &f.scalar_type] { + hash_text(&mut h, t); + } + hash_optional(&mut h, f.evaluation_time); + for n in f.dimensions { + h.update(n.to_le_bytes()); + } + h.update((f.components as u64).to_le_bytes()); + h.update((f.data.len() as u64).to_le_bytes()); + h.update(&f.data); + } + h.finalize().into() +} +/// Analysis-v1 identity of exact run metadata; does not validate caller-built metadata. +/// Use `RecordingReader::run()` for verified persisted source facts. +pub fn run_digest(run: &RecordingRun) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"rheon-native-run-v1\0"); + h.update(run.identity.run_id.to_le_bytes()); + for t in [ + &run.identity.source_revision, + &run.solver, + &run.configuration, + &run.provenance, + ] { + hash_text(&mut h, t); + } + for v in run.counts { + h.update(v.to_le_bytes()); + } + for v in run.spacing.into_iter().chain(run.origin) { + h.update(v.to_le_bytes()); + } + h.finalize().into() +} +fn hash_text(h: &mut Sha256, text: &str) { + h.update((text.len() as u64).to_le_bytes()); + h.update(text.as_bytes()); +} +fn hash_optional(h: &mut Sha256, v: Option) { + h.update([u8::from(v.is_some())]); + if let Some(v) = v { + h.update(v.to_le_bytes()); + } +} + +#[cfg(feature = "desktop")] +impl<'de> serde::Deserialize<'de> for FieldComparison { + fn deserialize>(deserializer: D) -> Result { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct WireField { + metrics: Metrics, + units: String, + color_bounds: ColorBounds, + #[serde(deserialize_with = "deserialize_explicit_option")] + gauge: Option, + } + let field = WireField::deserialize(deserializer)?; + let units = match field.units.as_str() { + "1" => "1", + "Pa" => "Pa", + "m/s" => "m/s", + "1/s" => "1/s", + _ => { + return Err(serde::de::Error::custom( + "unsupported native comparison units", + )); + } + }; + Ok(Self { + metrics: field.metrics, + units, + color_bounds: field.color_bounds, + gauge: field.gauge, + }) + } +} + +#[cfg(feature = "desktop")] +fn deserialize_explicit_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + serde::Deserialize::deserialize(deserializer) +} diff --git a/tests/comparison_archive_contract.rs b/tests/comparison_archive_contract.rs new file mode 100644 index 00000000..cb52beea --- /dev/null +++ b/tests/comparison_archive_contract.rs @@ -0,0 +1,624 @@ +#![cfg(all(feature = "desktop", not(target_arch = "wasm32")))] +use rheon::{PressureImplementation, SnapshotStage, recording}; +#[path = "../src/bin/desktop/comparison_archive.rs"] +mod comparison_archive; +// These path-loaded sibling modules retain APIs covered by their own contracts. +#[allow(dead_code)] +#[path = "../src/recording/comparison.rs"] +mod native_comparison; +#[allow(dead_code)] +#[path = "../src/bin/desktop/run_sets.rs"] +mod run_sets; +use comparison_archive::*; +use native_comparison::{Coverage, FieldRole, PressurePolicy, Recipe}; +use recording::*; +use run_sets::*; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::atomic::{AtomicUsize, Ordering}, +}; + +struct Directory(PathBuf); +impl Directory { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "rheon-comparison-archive-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } +} +impl Drop for Directory { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } +} +fn spec(method: NativePressureMethod) -> NativeCarrierSpec { + NativeCarrierSpec { + counts_xyz: [2, 3, 4], + spacing_m: [0.25, 0.5, 0.75], + origin_m: [-1.0, 2.0, 3.0], + density_kg_m3: 1.0, + pressure_method: method, + tolerances: NativeTolerances { + relative_residual: 1e-10, + absolute_residual_m3_s2: 1e-12, + predicted_divergence_per_s: 1e-8, + actual_divergence_per_s: 1e-5, + max_courant: 1.0, + max_pressure_iterations: 2000, + }, + requested_dt_s: 0.003, + requested_steps: 3, + source: Some(SourceSpec { + lower_m: [-0.99, 2.1, 3.1], + upper_m: [-0.51, 2.9, 4.4], + tracer_rate_per_s: 2.0, + vertical_acceleration_m_s2: 1.0, + active_steps: 3, + }), + simulation_memory_limit_bytes: 64 * 1024 * 1024, + recording: ArchiveBudget { + max_database_bytes: 8 * 1024 * 1024, + max_record_bytes: 1024 * 1024, + max_records: 16, + }, + } +} +fn produce_member(root: &Path, m: &RunSetMember, detail: &str) { + let attempt = m.attempt.as_ref().unwrap(); + let spec = &m.spec.spec; + let mut native = spec.create_simulation().unwrap(); + let run = bound_native_run( + &m.spec, + &native, + rheon::SessionIdentity { + run_id: attempt.expected_run_id, + source_revision: attempt.expected_source_revision.clone(), + }, + "native comparison artifact fixture; no model runtime", + ) + .unwrap(); + let mut writer = StepRecorder::create( + &root.join(&attempt.archive_relative_path), + run, + spec.recording.limits(), + ) + .unwrap(); + writer + .append(&carrier_publication(&native, 0, spec.recording.max_record_bytes).unwrap()) + .unwrap(); + for interval in 0..spec.requested_steps { + native + .step(spec.requested_dt_s, spec.source_at(interval), |_| false) + .unwrap(); + writer + .append( + &carrier_publication(&native, interval + 1, spec.recording.max_record_bytes) + .unwrap(), + ) + .unwrap(); + } + writer + .finish("completed", detail, Some(native.state().generation)) + .unwrap(); +} +fn produce(root: &Path) -> RunSetRevision { + let mut set = RunSetRevision { + schema_version: 1, + set_id: "saved-native-comparison".into(), + parent_sha256: None, + baseline_member: "jacobi".into(), + members: Vec::new(), + }; + for (id, method, run_id) in [ + ("jacobi", NativePressureMethod::JacobiPcgV1, 101), + ("sgs", NativePressureMethod::SymmetricGaussSeidelPcgV1, 102), + ] { + let mut m = RunSetMember { + member_id: id.into(), + spec: FrozenSpec::new(spec(method)).unwrap(), + differences_from_baseline: if id == "jacobi" { + vec![] + } else { + vec![SpecDifference::PressureMethod] + }, + attempt: Some(RunAttempt { + attempt_id: format!("attempt-{id}"), + archive_relative_path: format!("{id}.sqlite"), + expected_run_id: run_id, + expected_source_revision: "actual-artifact-native-test-source".into(), + finalized: None, + }), + }; + produce_member(root, &m, "native fixture completed"); + m.attempt + .as_mut() + .unwrap() + .pin_closed(root, &m.spec) + .unwrap(); + set.members.push(m); + } + set.save_new(&root.join("run-set.json")).unwrap(); + set +} +fn recipe(root: &Path, set: RunSetRevision) -> SavedRecipe { + SavedRecipe::capture( + root, + set, + "jacobi", + "sgs", + &[1, 2, 3], + &[1, 2], + Recipe { + field: FieldRole::IntervalPressure, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + }, + ) + .unwrap() +} +fn saved(root: &Path) -> (SavedRecipe, PathBuf) { + let r = recipe(root, produce(root)); + let path = root.join("comparison.json"); + r.compute(root).unwrap().save_new(&path).unwrap(); + (r, path) +} +#[derive(Serialize, Deserialize)] +struct FixtureEnvelope { + schema_version: u32, + kind: String, + sha256: [u8; 32], + payload: T, +} +#[derive(Serialize, Deserialize)] +struct FixtureRecord { + recipe: SavedRecipe, + coverage: Coverage, +} +fn mutate_result(path: &Path, mutate: impl FnOnce(&mut FixtureRecord)) { + let mut envelope: FixtureEnvelope = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + mutate(&mut envelope.payload); + envelope.sha256 = Sha256::digest(serde_json::to_vec(&envelope.payload).unwrap()).into(); + std::fs::write(path, serde_json::to_vec(&envelope).unwrap()).unwrap(); +} + +#[test] +fn comparison_archive_child() { + let Ok(mode) = std::env::var("RHEON_COMPARISON_ARCHIVE_CHILD_MODE") else { + return; + }; + let root = PathBuf::from(std::env::var_os("RHEON_COMPARISON_ARCHIVE_CHILD_ROOT").unwrap()); + match mode.as_str() { + "produce" => { + let r = recipe(&root, produce(&root)); + r.save_new(&root.join("recipe.json")).unwrap(); + r.compute(&root) + .unwrap() + .save_new(&root.join("result.json")) + .unwrap(); + } + "verify" => { + let r = SavedRecipe::open(&root.join("recipe.json")).unwrap(); + let raw = UnverifiedComparison::open(&root.join("result.json")).unwrap(); + assert_eq!(raw.recipe(), &r); + assert_eq!(raw.recorded_coverage().pairs.len(), 2); + let verified = raw.verify(&root).unwrap(); + assert_eq!(verified.recipe().sha256().unwrap(), r.sha256().unwrap()); + assert_eq!(verified.coverage().reference_unmatched.len(), 1); + assert!(verified.coverage().candidate_unmatched.is_empty()); + assert!( + verified.coverage().pairs.iter().all(|p| p + .outcome + .as_ref() + .unwrap() + .metrics + .support_count + == 24) + ); + assert_eq!( + verified.sha256().unwrap(), + r.compute(&root).unwrap().sha256().unwrap() + ); + } + _ => panic!("unexpected child mode"), + } +} +#[test] +fn real_producer_process_exits_then_fresh_process_reopens_recipe_and_verifies_result() { + let root = Directory::new(); + for mode in ["produce", "verify"] { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "comparison_archive_child", "--nocapture"]) + .env("RHEON_COMPARISON_ARCHIVE_CHILD_MODE", mode) + .env("RHEON_COMPARISON_ARCHIVE_CHILD_ROOT", &root.0) + .status() + .unwrap(); + assert!(status.success(), "child {mode}"); + } +} +#[test] +fn no_overwrite_and_missing_source_never_substitutes_latest_or_claims_verified() { + let root = Directory::new(); + let (r, path) = saved(&root.0); + let bytes = std::fs::read(&path).unwrap(); + let verified = r.compute(&root.0).unwrap(); + assert!( + matches!(verified.save_new(&path),Err(ComparisonArchiveError::Artifact(RunSetError::Io(e))) if e.kind()==std::io::ErrorKind::AlreadyExists) + ); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let raw = UnverifiedComparison::open(&path).unwrap(); + std::fs::rename( + root.0.join("jacobi.sqlite"), + root.0.join("newer-jacobi.sqlite"), + ) + .unwrap(); + assert!(matches!( + raw.verify(&root.0), + Err(ComparisonArchiveError::Source { + side: Side::Reference, + problem: SourceProblem::Missing + }) + )); + // The file is still readable as explicitly unverified derived evidence. + assert_eq!( + UnverifiedComparison::open(&path) + .unwrap() + .recorded_coverage() + .pairs + .len(), + 2 + ); +} +#[test] +fn changed_terminal_artifact_and_corrupt_source_are_explicit() { + let root = Directory::new(); + let (r, path) = saved(&root.0); + let raw = UnverifiedComparison::open(&path).unwrap(); + std::fs::remove_file(root.0.join("sgs.sqlite")).unwrap(); + produce_member( + &root.0, + &r.run_set.members[1], + "different terminal receipt; exact publication fields unchanged", + ); + assert!(matches!( + raw.verify(&root.0), + Err(ComparisonArchiveError::Source { + side: Side::Candidate, + problem: SourceProblem::Invalid(RunSetError::Integrity("finalized archive changed")) + }) + )); + std::fs::write(root.0.join("jacobi.sqlite"), b"corrupt sqlite bytes").unwrap(); + assert!(matches!( + raw.verify(&root.0), + Err(ComparisonArchiveError::Source { + side: Side::Reference, + problem: SourceProblem::Invalid(_) + }) + )); +} +#[test] +fn exact_selection_hash_and_plausible_rehashed_metric_are_checked_by_recomputation() { + let root = Directory::new(); + let (mut r, path) = saved(&root.0); + r.reference.selections[0].sha256[0] ^= 1; + assert!(matches!( + r.compute(&root.0), + Err(ComparisonArchiveError::Source { + side: Side::Reference, + problem: SourceProblem::SelectionChanged + }) + )); + mutate_result(&path, |record| { + record.coverage.pairs[0] + .outcome + .as_mut() + .unwrap() + .metrics + .l2 += 1.0; + }); + let raw = UnverifiedComparison::open(&path).unwrap(); + assert!(matches!( + raw.verify(&root.0), + Err(ComparisonArchiveError::Integrity( + "result differs from exact source recomputation" + )) + )); +} +#[test] +fn unknown_versions_enums_duplicate_fields_truncation_and_hash_damage_reject_precisely() { + let root = Directory::new(); + let (_, path) = saved(&root.0); + let original = std::fs::read_to_string(&path).unwrap(); + std::fs::write( + &path, + original.replacen("\"schema_version\":1", "\"schema_version\":999999999999", 1), + ) + .unwrap(); + assert!(matches!( + UnverifiedComparison::open(&path), + Err(ComparisonArchiveError::Artifact(RunSetError::Unsupported( + "manifest version" + ))) + )); + std::fs::write( + &path, + original.replacen("\"interval_pressure\"", "\"unknown_native_role\"", 1), + ) + .unwrap(); + assert!( + matches!(UnverifiedComparison::open(&path),Err(ComparisonArchiveError::Artifact(RunSetError::Codec(e))) if e.is_data()) + ); + std::fs::write( + &path, + original.replacen( + "\"schema_version\":1", + "\"schema_version\":1,\"schema_version\":1", + 1, + ), + ) + .unwrap(); + assert!( + matches!(UnverifiedComparison::open(&path),Err(ComparisonArchiveError::Artifact(RunSetError::Codec(e))) if e.is_data()) + ); + std::fs::write(&path, &original.as_bytes()[..original.len() / 2]).unwrap(); + assert!( + matches!(UnverifiedComparison::open(&path),Err(ComparisonArchiveError::Artifact(RunSetError::Codec(e))) if e.is_eof()) + ); + std::fs::write(&path, &original).unwrap(); + let mut envelope: FixtureEnvelope = serde_json::from_str(&original).unwrap(); + envelope.sha256[0] ^= 1; + std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert!(matches!( + UnverifiedComparison::open(&path), + Err(ComparisonArchiveError::Integrity("artifact payload digest")) + )); + std::fs::write(&path, &original).unwrap(); + mutate_result(&path, |record| record.recipe.analysis_version = 2); + assert!(matches!( + UnverifiedComparison::open(&path), + Err(ComparisonArchiveError::Unsupported("analysis version")) + )); +} +#[test] +fn rehashed_structural_result_damage_is_invalid_before_any_source_access() { + let root = Directory::new(); + let (_, path) = saved(&root.0); + let original = std::fs::read(&path).unwrap(); + for mutation in 0..5 { + std::fs::write(&path, &original).unwrap(); + mutate_result(&path, |record| { + let pair = &mut record.coverage.pairs[0]; + match mutation { + 0 => pair.outcome.as_mut().unwrap().metrics.rmse = -1.0, + 1 => pair.outcome.as_mut().unwrap().color_bounds.min = f64::MAX, + 2 => { + pair.outcome + .as_mut() + .unwrap() + .gauge + .as_mut() + .unwrap() + .diagnostic = true + } + 3 => pair.outcome.as_mut().unwrap().units = "1", + 4 => { + record.coverage.pairs.pop(); + } + _ => unreachable!(), + } + }); + let expected = match mutation { + 0 | 1 => "finite result metrics and bounds", + 2 => "pressure gauge report", + 3 => "result units", + 4 => "result selection coverage", + _ => unreachable!(), + }; + assert!( + matches!(UnverifiedComparison::open(&path),Err(ComparisonArchiveError::Invalid(r)) if r==expected), + "mutation {mutation}" + ); + } +} +#[test] +fn bounded_ordered_selection_contract_and_recipe_hash_are_checked() { + let root = Directory::new(); + let set = produce(&root.0); + let r = Recipe { + field: FieldRole::Tracer, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + }; + for ordinals in [vec![], vec![1, 1], vec![2, 1], (0..129).collect()] { + assert!(matches!( + SavedRecipe::capture(&root.0, set.clone(), "jacobi", "sgs", &ordinals, &[1], r), + Err(ComparisonArchiveError::Invalid( + "ordered bounded selections" + )) + )); + } + let mut saved = recipe(&root.0, set); + saved.run_set_sha256[0] ^= 1; + assert!(matches!( + saved.validate(), + Err(ComparisonArchiveError::Integrity("run-set revision digest")) + )); +} + +#[test] +fn all_native_units_and_explicit_gauge_zero_reference_semantics_round_trip() { + let root = Directory::new(); + let set = produce(&root.0); + for (i, field) in [ + FieldRole::Tracer, + FieldRole::IntervalPressure, + FieldRole::VelocityX, + FieldRole::VelocityY, + FieldRole::VelocityZ, + FieldRole::VelocityDivergence, + ] + .into_iter() + .enumerate() + { + let r = SavedRecipe::capture( + &root.0, + set.clone(), + "jacobi", + "sgs", + &[1, 2], + &[1, 2], + Recipe { + field, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + }, + ) + .unwrap(); + let original = r.compute(&root.0).unwrap(); + let path = root.0.join(format!("role-{i}.json")); + original.save_new(&path).unwrap(); + let raw = UnverifiedComparison::open(&path).unwrap(); + let verified = raw.verify(&root.0).unwrap(); + assert_eq!(verified.sha256().unwrap(), original.sha256().unwrap()); + assert_eq!(verified.coverage(), original.coverage()); + } + let zero_recipe = SavedRecipe::capture( + &root.0, + set.clone(), + "jacobi", + "sgs", + &[0], + &[0], + Recipe { + field: FieldRole::Tracer, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + }, + ) + .unwrap(); + let zero = zero_recipe.compute(&root.0).unwrap(); + let zero_path = root.0.join("zero-reference.json"); + zero.save_new(&zero_path).unwrap(); + let verified = UnverifiedComparison::open(&zero_path) + .unwrap() + .verify(&root.0) + .unwrap(); + let field = verified.coverage().pairs[0].outcome.as_ref().unwrap(); + assert_eq!(field.metrics.relative_l2, None); + assert_eq!(field.metrics.l2, 0.0); + assert_eq!( + field.color_bounds, + native_comparison::ColorBounds { min: 0.0, max: 0.0 } + ); + let mut r = recipe(&root.0, set); + r.recipe.pressure = PressurePolicy::RemoveMeanOffsetDiagnostic; + let original = r.compute(&root.0).unwrap(); + let path = root.0.join("offset.json"); + original.save_new(&path).unwrap(); + let verified = UnverifiedComparison::open(&path) + .unwrap() + .verify(&root.0) + .unwrap(); + assert!( + verified.coverage().pairs[0] + .outcome + .as_ref() + .unwrap() + .gauge + .unwrap() + .diagnostic + ); +} +#[test] +fn unavailable_geometry_outcomes_and_unmatched_coverage_are_preserved() { + let root = Directory::new(); + let mut set = produce(&root.0); + let candidate = &mut set.members[1]; + std::fs::remove_file(root.0.join("sgs.sqlite")).unwrap(); + let mut altered = candidate.spec.spec.clone(); + altered.counts_xyz[2] += 1; + candidate.spec = FrozenSpec::new(altered).unwrap(); + candidate.differences_from_baseline = + vec![SpecDifference::Geometry, SpecDifference::PressureMethod]; + candidate.attempt.as_mut().unwrap().finalized = None; + produce_member(&root.0, candidate, "different recorded grid"); + candidate + .attempt + .as_mut() + .unwrap() + .pin_closed(&root.0, &candidate.spec) + .unwrap(); + let r = recipe(&root.0, set); + let computed = r.compute(&root.0).unwrap(); + let path = root.0.join("unavailable.json"); + computed.save_new(&path).unwrap(); + let verified = UnverifiedComparison::open(&path) + .unwrap() + .verify(&root.0) + .unwrap(); + assert_eq!(verified.coverage().pairs.len(), 2); + assert_eq!(verified.coverage().reference_unmatched.len(), 1); + assert!( + verified + .coverage() + .pairs + .iter() + .all(|p| p.outcome == Err(native_comparison::Unavailable::Geometry)) + ); +} +#[test] +fn required_nullable_facts_and_unknown_keys_are_not_silently_defaulted() { + let root = Directory::new(); + let (_, path) = saved(&root.0); + let bytes = std::fs::read(&path).unwrap(); + for target in ["relative_l2", "gauge", "extra_fact"] { + let mut document: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let field = &mut document["payload"]["coverage"]["pairs"][0]["outcome"]["Ok"]; + match target { + "relative_l2" => { + field["metrics"] + .as_object_mut() + .unwrap() + .remove("relative_l2"); + } + "gauge" => { + field.as_object_mut().unwrap().remove("gauge"); + } + "extra_fact" => { + field + .as_object_mut() + .unwrap() + .insert("extra_fact".into(), serde_json::Value::Bool(true)); + } + _ => unreachable!(), + } + std::fs::write(&path, serde_json::to_vec(&document).unwrap()).unwrap(); + assert!( + matches!(UnverifiedComparison::open(&path),Err(ComparisonArchiveError::Artifact(RunSetError::Codec(e))) if e.is_data()), + "{target}" + ); + } +} + +#[test] +fn artifact_byte_budget_rejects_before_decoding_an_oversized_valid_json_file() { + let root = Directory::new(); + let (_, path) = saved(&root.0); + let mut bytes = std::fs::read(&path).unwrap(); + bytes.resize(1024 * 1024 + 1, b' '); + std::fs::write(&path, bytes).unwrap(); + assert!(matches!( + UnverifiedComparison::open(&path), + Err(ComparisonArchiveError::Artifact(RunSetError::Invalid( + "manifest byte limit" + ))) + )); +} diff --git a/tests/recording_comparison_contract.rs b/tests/recording_comparison_contract.rs new file mode 100644 index 00000000..4d08592d --- /dev/null +++ b/tests/recording_comparison_contract.rs @@ -0,0 +1,993 @@ +#![cfg(not(target_arch = "wasm32"))] +use rheon::{PressureImplementation, SnapshotStage, recording}; +#[path = "../src/recording/comparison.rs"] +mod comparison; +use comparison::*; +use recording::*; +use rheon::{GridGeometry, SessionIdentity, Simulation, SimulationConfig}; +use std::{ + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; + +struct Archive { + path: PathBuf, +} +impl Archive { + fn empty() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "rheon-m3-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&dir).unwrap(); + Self { + path: dir.join("archive.sqlite"), + } + } + fn new(run: RecordingRun, steps: &[RecordedStep]) -> Self { + let archive = Self::empty(); + let mut writer = + StepRecorder::create(&archive.path, run, RecordingLimits::default()).unwrap(); + for s in steps { + writer.append(s).unwrap(); + } + writer + .finish( + "completed", + "comparison fixture", + steps.last().map(|s| s.generation), + ) + .unwrap(); + archive + } + fn reader(&self) -> RecordingReader { + RecordingReader::open(&self.path, RecordingLimits::default()).unwrap() + } + fn select(&self, n: usize) -> Selection { + Selection::read(&self.reader(), n).unwrap() + } +} +impl Drop for Archive { + fn drop(&mut self) { + std::fs::remove_dir_all(self.path.parent().unwrap()).unwrap(); + } +} +fn sim() -> Simulation { + Simulation::new( + GridGeometry::new([2, 3, 4], [0.25, 0.5, 0.75], [-1.0, 2.0, 3.0]).unwrap(), + SimulationConfig { + density: 1.0, + memory_limit: 64 * 1024 * 1024, + pressure: rheon::PressureSettings { + relative_residual: 1e-10, + absolute_residual: 1e-12, + divergence_limit: 1e-8, + max_iterations: 2000, + }, + actual_divergence_limit: 1e-5, + max_courant: 1.0, + }, + ) + .unwrap() +} +fn fixture(dt: f64, constructor: bool) -> (RecordingRun, Vec) { + let mut s = sim(); + let run = carrier_run( + SessionIdentity { + run_id: 7, + source_revision: "comparison-fixture".into(), + }, + &s, + "actual native carrier capture; mutations are test data", + ) + .unwrap(); + let mut steps = Vec::new(); + if constructor { + steps.push(carrier_publication(&s, 0, 1 << 20).unwrap()); + } + s.step(dt, None, |_| false).unwrap(); + steps.push(carrier_publication(&s, steps.len(), 1 << 20).unwrap()); + (run, steps) +} +fn recipe(field: FieldRole) -> Recipe { + Recipe { + field, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + } +} +fn available(a: &Selection, b: &Selection, role: FieldRole) -> FieldComparison { + compare(a, b, recipe(role)).unwrap().outcome.unwrap() +} +fn field_mut<'a>(s: &'a mut RecordedStep, name: &str) -> &'a mut RecordedField { + s.fields.iter_mut().find(|f| f.name == name).unwrap() +} +fn put_f64(s: &mut RecordedStep, name: &str, v: impl Fn(usize) -> f64) { + let f = field_mut(s, name); + f.data = (0..f.data.len() / 8) + .flat_map(|i| v(i).to_le_bytes()) + .collect(); +} +fn put_f32(s: &mut RecordedStep, name: &str, v: impl Fn(usize) -> f32) { + let f = field_mut(s, name); + f.data = (0..f.data.len() / 4) + .flat_map(|i| v(i).to_le_bytes()) + .collect(); +} +fn hex(v: [u8; 32]) -> String { + v.iter().map(|b| format!("{b:02x}")).collect() +} + +#[test] +fn physical_interval_matches_across_different_ordinals_and_native_cold_sources() { + let (run, steps) = fixture(0.01, true); + let a = Archive::new(run, &steps); + let (run, steps) = fixture(0.01, false); + let b = Archive::new(run, &steps); + let result = available(&a.select(1), &b.select(0), FieldRole::IntervalPressure); + assert_eq!(result.metrics.l2, 0.0); + assert_eq!(result.metrics.support_count, 24); + let coverage = align( + &[a.select(0), a.select(1)], + &[b.select(0)], + recipe(FieldRole::IntervalPressure), + ) + .unwrap(); + assert_eq!(coverage.analysis_version, 1); + assert_eq!(coverage.recipe, recipe(FieldRole::IntervalPressure)); + assert_eq!(coverage.pairs[0].analysis_version, 1); + assert_eq!(coverage.pairs[0].recipe, coverage.recipe); + assert_eq!(coverage.pairs.len(), 1); + assert_eq!(coverage.reference_unmatched.len(), 1); + assert!(coverage.candidate_unmatched.is_empty()); +} +#[test] +fn equal_ordinal_does_not_match_unequal_time_or_projection_interval() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + let (run, steps) = fixture(0.02, false); + let b = Archive::new(run, &steps); + assert_eq!( + compare(&a.select(0), &b.select(0), recipe(FieldRole::Tracer)) + .unwrap() + .outcome, + Err(Unavailable::PhysicalTime) + ); + let coverage = align(&[a.select(0)], &[b.select(0)], recipe(FieldRole::Tracer)).unwrap(); + assert_eq!(coverage.reference_unmatched.len(), 1); + assert_eq!(coverage.candidate_unmatched.len(), 1); + let mut native = sim(); + native.step(0.01, None, |_| false).unwrap(); + let first = carrier_publication(&native, 0, 1 << 20).unwrap(); + native.step(0.01, None, |_| false).unwrap(); + let second = carrier_publication(&native, 1, 1 << 20).unwrap(); + let run = carrier_run( + SessionIdentity { + run_id: 7, + source_revision: "interval-fixture".into(), + }, + &native, + "two actual native intervals", + ) + .unwrap(); + let a = Archive::new(run, &[first, second]); + assert_eq!( + compare( + &a.select(1), + &b.select(0), + recipe(FieldRole::IntervalPressure) + ) + .unwrap() + .outcome, + Err(Unavailable::PhysicalTime) + ); + assert_eq!( + available(&a.select(1), &b.select(0), FieldRole::Tracer) + .metrics + .l2, + 0.0 + ); +} +#[test] +fn stage_geometry_and_unknown_profile_are_explicit() { + let (run, steps) = fixture(0.01, true); + let a = Archive::new(run, &steps); + assert_eq!( + compare(&a.select(0), &a.select(1), recipe(FieldRole::Tracer)) + .unwrap() + .outcome, + Err(Unavailable::PublicationStage) + ); + let (mut run, steps) = fixture(0.01, true); + run.origin[0] += 1.0; + let b = Archive::new(run, &steps); + assert_eq!( + compare(&a.select(1), &b.select(1), recipe(FieldRole::Tracer)) + .unwrap() + .outcome, + Err(Unavailable::Geometry) + ); + let (mut run, steps) = fixture(0.01, true); + run.solver = "future-profile/unknown".into(); + let b = Archive::new(run, &steps); + assert_eq!( + compare(&a.select(1), &b.select(1), recipe(FieldRole::Tracer)) + .unwrap() + .outcome, + Err(Unavailable::UnsupportedProfile) + ); + let (run, mut steps) = fixture(0.01, true); + field_mut(&mut steps[1], "tracer").stage = "tentative".into(); + let b = Archive::new(run, &steps); + assert_eq!( + compare(&a.select(1), &b.select(1), recipe(FieldRole::Tracer)) + .unwrap() + .outcome, + Err(Unavailable::FieldSchema) + ); +} +#[test] +fn missing_field_remains_unavailable_inside_matched_coverage() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + steps[0].fields.retain(|f| f.name != "tracer"); + let b = Archive::new(run, &steps); + let coverage = align(&[a.select(0)], &[b.select(0)], recipe(FieldRole::Tracer)).unwrap(); + assert_eq!(coverage.pairs[0].outcome, Err(Unavailable::MissingField)); + assert!(coverage.reference_unmatched.is_empty()); +} +#[test] +fn pressure_offset_diagnostic_retains_canonical_gauge_failure_and_offset() { + let (run, mut steps) = fixture(0.01, false); + put_f64(&mut steps[0], "interval_pressure", |i| i as f64); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + put_f64(&mut steps[0], "interval_pressure", |i| i as f64 + 8.0); + let b = Archive::new(run, &steps); + assert_eq!( + compare( + &a.select(0), + &b.select(0), + recipe(FieldRole::IntervalPressure) + ) + .unwrap() + .outcome, + Err(Unavailable::NoncanonicalGauge) + ); + let mut r = recipe(FieldRole::IntervalPressure); + r.pressure = PressurePolicy::RemoveMeanOffsetDiagnostic; + let result = compare(&a.select(0), &b.select(0), r) + .unwrap() + .outcome + .unwrap(); + assert!(result.metrics.linf < 1e-13); + let gauge = result.gauge.unwrap(); + assert!(gauge.reference_canonical); + assert!(!gauge.candidate_canonical); + assert!(gauge.diagnostic); + assert!((gauge.removed_offset_pa - 8.0).abs() < 1e-13); + oracle_with_policy(&a, &b, FieldRole::IntervalPressure, &result, true); +} +#[test] +fn analytic_native_f32_metrics_zero_denominator_and_common_bounds() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + put_f32( + &mut steps[0], + "tracer", + |i| if i % 2 == 0 { 0.25 } else { 0.5 }, + ); + let b = Archive::new(run, &steps); + let result = available(&a.select(0), &b.select(0), FieldRole::Tracer); + assert_eq!(result.metrics.relative_l2, None); + assert!((result.metrics.l2 - (12.0_f64 * 0.3125).sqrt()).abs() < 1e-14); + assert!((result.metrics.rmse - 0.15625_f64.sqrt()).abs() < 1e-14); + assert_eq!(result.metrics.linf, 0.5); + assert_eq!(result.color_bounds, ColorBounds { min: 0.0, max: 0.5 }); + oracle(&a, &b, FieldRole::Tracer, &result); +} +fn analytic_faces(s: &mut RecordedStep, scale: f32) { + put_f32(s, "velocity_x", |i| scale * (i % 3) as f32 * 0.25); + put_f32(s, "velocity_y", |i| { + scale * 2.0 * ((i / 2) % 4) as f32 * 0.5 + }); + put_f32(s, "velocity_z", |i| -scale * (i / 6) as f32 * 0.75); +} +#[test] +fn divergence_uses_native_staggered_faces_spacing_and_x_fast_indices() { + let (run, mut steps) = fixture(0.01, false); + analytic_faces(&mut steps[0], 1.0); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + analytic_faces(&mut steps[0], 2.0); + let b = Archive::new(run, &steps); + let result = available(&a.select(0), &b.select(0), FieldRole::VelocityDivergence); + assert!((result.metrics.l2 - 2.0 * 24.0_f64.sqrt()).abs() < 1e-13); + assert!((result.metrics.rmse - 2.0).abs() < 1e-14); + assert_eq!(result.metrics.linf, 2.0); + assert_eq!(result.metrics.relative_l2, Some(1.0)); + assert_eq!(result.units, "1/s"); + assert_eq!(result.color_bounds, ColorBounds { min: 2.0, max: 4.0 }); + oracle(&a, &b, FieldRole::VelocityDivergence, &result); + for role in [ + FieldRole::VelocityX, + FieldRole::VelocityY, + FieldRole::VelocityZ, + ] { + let result = available(&a.select(0), &b.select(0), role); + oracle(&a, &b, role, &result); + } +} +fn oracle(a: &Archive, b: &Archive, role: FieldRole, result: &FieldComparison) { + oracle_with_policy(a, b, role, result, false); +} +fn oracle_with_policy( + a: &Archive, + b: &Archive, + role: FieldRole, + result: &FieldComparison, + diagnostic: bool, +) { + let name = match role { + FieldRole::Tracer => "tracer", + FieldRole::VelocityDivergence => "divergence", + FieldRole::VelocityX => "velocity_x", + FieldRole::VelocityY => "velocity_y", + FieldRole::VelocityZ => "velocity_z", + FieldRole::IntervalPressure => "interval_pressure", + }; + let mut command = std::process::Command::new("python3"); + command.arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tools/verify_recording_comparison.py" + )); + if diagnostic { + command.arg("--remove-mean-offset"); + } + let status = command + .arg(&a.path) + .arg(&b.path) + .args(["--field", name, "--expect"]) + .arg(format!("{:.17e}", result.metrics.l2)) + .arg(format!("{:.17e}", result.metrics.rmse)) + .arg(format!("{:.17e}", result.metrics.linf)) + .arg( + result + .metrics + .relative_l2 + .map_or("undefined".into(), |v| format!("{v:.17e}")), + ) + .arg("--expect-selection") + .arg(hex(a.select(0).digest())) + .arg("--expect-candidate-selection") + .arg(hex(b.select(0).digest())) + .status() + .unwrap(); + assert!(status.success()); +} +#[test] +fn append_and_terminal_status_do_not_change_selected_identity() { + let mut s = sim(); + let run = carrier_run( + SessionIdentity { + run_id: 99, + source_revision: "append-source".into(), + }, + &s, + "append identity", + ) + .unwrap(); + let archive = Archive::empty(); + let mut writer = StepRecorder::create(&archive.path, run, RecordingLimits::default()).unwrap(); + writer + .append(&carrier_publication(&s, 0, 1 << 20).unwrap()) + .unwrap(); + let reader = archive.reader(); + let before = Selection::read(&reader, 0).unwrap(); + s.step(0.01, None, |_| false).unwrap(); + writer + .append(&carrier_publication(&s, 1, 1 << 20).unwrap()) + .unwrap(); + writer.finish("completed", "done", Some(1)).unwrap(); + drop(writer); + let after = archive.select(0); + assert_eq!(before.identity(), after.identity()); + assert_eq!(before.run_digest(), after.run_digest()); + assert_eq!(after.run().identity.run_id, 99); + assert_eq!(after.publication().generation, 0); +} +#[test] +fn digest_changes_with_native_bits_and_source_provenance() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + put_f32(&mut steps[0], "tracer", |_| f32::from_bits(1)); + let b = Archive::new(run, &steps); + assert_ne!(a.select(0).digest(), b.select(0).digest()); + assert_eq!(a.select(0).run_digest(), b.select(0).run_digest()); + let (mut run, steps) = fixture(0.01, false); + run.provenance = "different provenance".into(); + let b = Archive::new(run, &steps); + assert_ne!(a.select(0).run_digest(), b.select(0).run_digest()); +} +#[test] +fn tolerance_is_explicit_and_ambiguous_matching_never_picks_by_order() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + let mut r = recipe(FieldRole::Tracer); + r.time_tolerance = f64::NAN; + assert_eq!( + compare(&a.select(0), &a.select(0), r), + Err(RecipeError::InvalidTolerance) + ); + r.time_tolerance = 0.1; + assert_eq!( + align(&[a.select(0)], &[a.select(0), a.select(0)], r), + Err(RecipeError::AmbiguousAlignment) + ); + assert_eq!( + align(&[a.select(0), a.select(0)], &[a.select(0)], r), + Err(RecipeError::AmbiguousAlignment) + ); +} + +#[test] +fn exact_field_schemas_reject_single_mismatches_and_divergence_missing_faces() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + for mutation in 0..5 { + let (run, mut steps) = fixture(0.01, false); + let field = field_mut(&mut steps[0], "tracer"); + match mutation { + 0 => field.units = "kg".into(), + 1 => field.association = "particle".into(), + 2 => field.dimensions = [3, 2, 4], + 3 => field.evaluation_time = Some(0.0), + 4 => field.stage = "diagnostic_rejected".into(), + _ => unreachable!(), + } + let b = Archive::new(run, &steps); + assert_eq!( + compare(&a.select(0), &b.select(0), recipe(FieldRole::Tracer)) + .unwrap() + .outcome, + Err(Unavailable::FieldSchema), + "mutation {mutation}" + ); + } + let (run, mut steps) = fixture(0.01, false); + steps[0].fields.retain(|f| f.name != "velocity_z"); + let b = Archive::new(run, &steps); + assert_eq!( + compare( + &a.select(0), + &b.select(0), + recipe(FieldRole::VelocityDivergence) + ) + .unwrap() + .outcome, + Err(Unavailable::MissingField) + ); +} +#[test] +fn finite_source_overflow_is_unavailable_and_nonfinite_source_is_rejected() { + let (run, mut steps) = fixture(0.01, false); + put_f64(&mut steps[0], "interval_pressure", |i| { + if i == 0 { 0.0 } else { f64::MAX } + }); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + put_f64(&mut steps[0], "interval_pressure", |i| { + if i == 0 { 0.0 } else { -f64::MAX } + }); + let b = Archive::new(run, &steps); + assert_eq!( + compare( + &a.select(0), + &b.select(0), + recipe(FieldRole::IntervalPressure) + ) + .unwrap() + .outcome, + Err(Unavailable::ArithmeticRange) + ); + for role in [FieldRole::Tracer, FieldRole::IntervalPressure] { + let archive = Archive::empty(); + let (run, mut steps) = fixture(0.01, false); + if role == FieldRole::Tracer { + put_f32(&mut steps[0], "tracer", |_| f32::NAN); + } else { + put_f64(&mut steps[0], "interval_pressure", |_| f64::INFINITY); + } + let mut writer = + StepRecorder::create(&archive.path, run, RecordingLimits::default()).unwrap(); + assert!(matches!( + writer.append(&steps[0]), + Err(RecordingError::Invalid("nonfinite field")) + )); + assert_eq!(archive.reader().count().unwrap(), 0); + } +} +#[test] +fn positive_tolerance_is_retained_and_constant_bounds_stay_finite() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + let (run, steps) = fixture(0.0101, false); + let b = Archive::new(run, &steps); + let mut r = recipe(FieldRole::Tracer); + r.time_tolerance = 0.001; + let result = compare(&a.select(0), &b.select(0), r).unwrap(); + assert_eq!(result.recipe, r); + let metrics = result.outcome.unwrap(); + assert_eq!(metrics.metrics.relative_l2, None); + assert_eq!(metrics.metrics.l2, 0.0); + assert_eq!(metrics.color_bounds, ColorBounds { min: 0.0, max: 0.0 }); + assert_eq!( + align(&[a.select(0)], &[b.select(0)], r) + .unwrap() + .pairs + .len(), + 1 + ); +} + +#[test] +fn real_jacobi_and_sgs_cold_archives_compare_common_source_and_unmatched_horizon() { + let record = |method, intervals| { + let template = sim(); + let mut native = Simulation::with_implementation( + GridGeometry::new([2, 3, 4], [0.25, 0.5, 0.75], [-1.0, 2.0, 3.0]).unwrap(), + template.config(), + method, + ) + .unwrap(); + let run = carrier_run( + SessionIdentity { + run_id: if method == PressureImplementation::JacobiPcgV1 { + 101 + } else { + 102 + }, + source_revision: "real-native-comparison-test-source".into(), + }, + &native, + "actual CPU native sealed-box source; no manufactured field mutation", + ) + .unwrap(); + let source = rheon::SmokeSource { + lower: [-0.99, 2.1, 3.1], + upper: [-0.51, 2.9, 4.4], + tracer_rate: 2.0, + vertical_acceleration: 1.0, + }; + let mut steps = Vec::new(); + for ordinal in 0..intervals { + native.step(0.003, Some(source), |_| false).unwrap(); + steps.push(carrier_publication(&native, ordinal, 1 << 20).unwrap()); + } + drop(native); + Archive::new(run, &steps) + }; + let jacobi = record(PressureImplementation::JacobiPcgV1, 3); + let sgs = record(PressureImplementation::SymmetricGaussSeidelPcgV1, 2); + let a = jacobi.reader(); + let b = sgs.reader(); + a.verify().unwrap(); + b.verify().unwrap(); + assert_eq!(a.status().unwrap().outcome, "completed"); + assert_eq!(b.status().unwrap().outcome, "completed"); + let reference: Vec<_> = (0..3).map(|i| Selection::read(&a, i).unwrap()).collect(); + let candidate: Vec<_> = (0..2).map(|i| Selection::read(&b, i).unwrap()).collect(); + let coverage = align(&reference, &candidate, recipe(FieldRole::IntervalPressure)).unwrap(); + assert_eq!(coverage.pairs.len(), 2); + assert_eq!( + coverage.reference_unmatched, + vec![reference[2].identity().clone()] + ); + assert!(coverage.candidate_unmatched.is_empty()); + assert_eq!(coverage.pairs[0].reference, *reference[0].identity()); + assert_eq!(coverage.pairs[0].candidate, *candidate[0].identity()); + assert_ne!(reference[0].digest(), candidate[0].digest()); + let decoded = RecordedCarrier::decode(a.run(), reference[0].publication()).unwrap(); + assert!(decoded.tracer.iter().any(|v| *v > 0.0)); + assert!( + decoded + .interval_pressure + .as_ref() + .unwrap() + .1 + .iter() + .any(|v| *v != 0.0) + ); + for role in [ + FieldRole::Tracer, + FieldRole::IntervalPressure, + FieldRole::VelocityX, + FieldRole::VelocityY, + FieldRole::VelocityZ, + FieldRole::VelocityDivergence, + ] { + let result = available(&reference[0], &candidate[0], role); + assert!(result.metrics.support_count > 0); + oracle(&jacobi, &sgs, role, &result); + } +} + +#[test] +fn malformed_pressure_interval_remains_a_paired_schema_failure() { + let (run, steps) = fixture(0.01, false); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + field_mut(&mut steps[0], "interval_pressure").evaluation_time = Some(0.005); + let b = Archive::new(run, &steps); + assert_eq!( + compare( + &a.select(0), + &b.select(0), + recipe(FieldRole::IntervalPressure) + ) + .unwrap() + .outcome, + Err(Unavailable::FieldSchema) + ); + let coverage = align( + &[a.select(0)], + &[b.select(0)], + recipe(FieldRole::IntervalPressure), + ) + .unwrap(); + assert_eq!(coverage.pairs.len(), 1); + assert_eq!(coverage.pairs[0].outcome, Err(Unavailable::FieldSchema)); + assert!(coverage.reference_unmatched.is_empty()); + assert!(coverage.candidate_unmatched.is_empty()); +} + +fn presentation_archive(method: PressureImplementation) -> Archive { + let mut native = Simulation::with_implementation( + GridGeometry::new([2, 3, 4], [0.25, 0.5, 0.75], [-1.0, 2.0, 3.0]).unwrap(), + sim().config(), + method, + ) + .unwrap(); + let run = carrier_run( + SessionIdentity { + run_id: if method == PressureImplementation::JacobiPcgV1 { + 301 + } else { + 302 + }, + source_revision: "actual-native-presentation-source".into(), + }, + &native, + "native fields; no presentation remapping", + ) + .unwrap(); + native + .step( + 0.003, + Some(rheon::SmokeSource { + lower: [-0.99, 2.1, 3.1], + upper: [-0.51, 2.9, 4.4], + tracer_rate: 2.0, + vertical_acceleration: 1.0, + }), + |_| false, + ) + .unwrap(); + Archive::new(run, &[carrier_publication(&native, 0, 1 << 20).unwrap()]) +} +fn native_bits(s: &Selection, name: &str) -> Vec { + let field = s.publication().field(name).unwrap(); + if field.scalar_type == "f64" { + field + .data + .chunks_exact(8) + .map(|b| f64::from_le_bytes(b.try_into().unwrap()).to_bits()) + .collect() + } else { + field + .data + .chunks_exact(4) + .map(|b| (f32::from_le_bytes(b.try_into().unwrap()) as f64).to_bits()) + .collect() + } +} +fn value_oracle( + a: &Archive, + b: &Archive, + role: FieldRole, + data: &ComparedValues, + diagnostic: bool, +) { + use std::io::Write; + use std::process::Stdio; + // Independent coordinate dictionaries, IEEE unpacking and fsum; this adds + // pointwise evidence without changing the existing metric oracle. + let script = r#" +import math, sqlite3, struct, sys + +def samples(path, role): + with sqlite3.connect(path) as db: + geometry=db.execute('SELECT geometry FROM run WHERE id=1').fetchone()[0] + fields={name:(kind,raw) for name,kind,raw in db.execute('SELECT name,scalar_type,data FROM fields WHERE ordinal=0')} + values={name:[v[0] for v in struct.iter_unpack(' "tracer", + FieldRole::IntervalPressure => "interval_pressure", + FieldRole::VelocityX => "velocity_x", + FieldRole::VelocityY => "velocity_y", + FieldRole::VelocityZ => "velocity_z", + FieldRole::VelocityDivergence => "divergence", + }; + let mut child = std::process::Command::new("python3") + .args(["-c", script]) + .arg(&a.path) + .arg(&b.path) + .arg(name) + .arg(if diagnostic { "diagnostic" } else { "direct" }) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + let bytes: Vec<_> = data + .reference + .iter() + .chain(&data.candidate) + .flat_map(|v| v.to_le_bytes()) + .collect(); + child.stdin.take().unwrap().write_all(&bytes).unwrap(); + assert!(child.wait().unwrap().success()); +} +#[test] +fn presentation_preserves_all_real_native_arrays_sampling_and_existing_oracle_metrics() { + let a = presentation_archive(PressureImplementation::JacobiPcgV1); + let b = presentation_archive(PressureImplementation::SymmetricGaussSeidelPcgV1); + let reference = a.select(0); + let candidate = b.select(0); + for (role, association, dimensions, offset, name) in [ + ( + FieldRole::Tracer, + SampleAssociation::CellCenter, + [2, 3, 4], + [0.5, 0.5, 0.5], + Some("tracer"), + ), + ( + FieldRole::IntervalPressure, + SampleAssociation::CellCenter, + [2, 3, 4], + [0.5, 0.5, 0.5], + Some("interval_pressure"), + ), + ( + FieldRole::VelocityX, + SampleAssociation::XFace, + [3, 3, 4], + [0.0, 0.5, 0.5], + Some("velocity_x"), + ), + ( + FieldRole::VelocityY, + SampleAssociation::YFace, + [2, 4, 4], + [0.5, 0.0, 0.5], + Some("velocity_y"), + ), + ( + FieldRole::VelocityZ, + SampleAssociation::ZFace, + [2, 3, 5], + [0.5, 0.5, 0.0], + Some("velocity_z"), + ), + ( + FieldRole::VelocityDivergence, + SampleAssociation::CellCenter, + [2, 3, 4], + [0.5, 0.5, 0.5], + None, + ), + ] { + let data = compared_values(&reference, &candidate, recipe(role)).unwrap(); + assert_eq!( + data.comparison, + compare(&reference, &candidate, recipe(role)).unwrap() + ); + assert_eq!( + data.geometry, + NativeGeometry { + counts_xyz: [2, 3, 4], + spacing_m: [0.25, 0.5, 0.75], + origin_m: [-1.0, 2.0, 3.0] + } + ); + assert_eq!( + data.sampling, + NativeSampling { + association, + dimensions, + offset_cells: offset + } + ); + assert_eq!( + data.reference.len(), + dimensions.into_iter().product::() as usize + ); + assert_eq!(data.candidate.len(), data.reference.len()); + if let Some(name) = name { + assert_eq!( + data.reference + .iter() + .map(|v| v.to_bits()) + .collect::>(), + native_bits(&reference, name) + ); + assert_eq!( + data.candidate + .iter() + .map(|v| v.to_bits()) + .collect::>(), + native_bits(&candidate, name) + ); + } + oracle(&a, &b, role, data.comparison.outcome.as_ref().unwrap()); + value_oracle(&a, &b, role, &data, false); + } +} +#[test] +fn presentation_keeps_native_bits_and_uses_only_declared_pressure_offset_and_divergence() { + let (run, mut steps) = fixture(0.01, false); + put_f32(&mut steps[0], "tracer", |i| match i % 3 { + 0 => -0.0, + 1 => f32::from_bits(1), + _ => f32::from_bits(0.25_f32.to_bits() + 1), + }); + put_f64(&mut steps[0], "interval_pressure", |i| i as f64); + analytic_faces(&mut steps[0], 1.0); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + put_f64(&mut steps[0], "interval_pressure", |i| i as f64 + 8.0); + analytic_faces(&mut steps[0], 2.0); + let b = Archive::new(run, &steps); + let x = a.select(0); + let y = b.select(0); + let tracer = compared_values(&x, &y, recipe(FieldRole::Tracer)).unwrap(); + assert_eq!(tracer.reference[0].to_bits(), (-0.0_f64).to_bits()); + assert_eq!(tracer.reference[1], f32::from_bits(1) as f64); + value_oracle(&a, &b, FieldRole::Tracer, &tracer, false); + let mut r = recipe(FieldRole::IntervalPressure); + r.pressure = PressurePolicy::RemoveMeanOffsetDiagnostic; + let pressure = compared_values(&x, &y, r).unwrap(); + let summary = pressure.comparison.outcome.as_ref().unwrap(); + let gauge = summary.gauge.unwrap(); + assert!(gauge.reference_canonical); + assert!(!gauge.candidate_canonical); + assert!(gauge.diagnostic); + assert_eq!(gauge.removed_offset_pa, 8.0); + assert_eq!(pressure.reference, pressure.candidate); + assert_eq!( + summary.color_bounds, + ColorBounds { + min: 0.0, + max: 23.0 + } + ); + value_oracle(&a, &b, FieldRole::IntervalPressure, &pressure, true); + let divergence = compared_values(&x, &y, recipe(FieldRole::VelocityDivergence)).unwrap(); + assert!(divergence.reference.iter().all(|v| *v == 2.0)); + assert!(divergence.candidate.iter().all(|v| *v == 4.0)); + value_oracle(&a, &b, FieldRole::VelocityDivergence, &divergence, false); +} +#[test] +fn presentation_refuses_recipe_and_native_pair_mutations_with_original_failure_meaning() { + let (run, steps) = fixture(0.01, true); + let a = Archive::new(run, &steps); + let reference = a.select(1); + let mut invalid = recipe(FieldRole::Tracer); + invalid.time_tolerance = f64::NAN; + assert_eq!( + compared_values(&reference, &reference, invalid).unwrap_err(), + ComparedValuesError::Recipe(RecipeError::InvalidTolerance) + ); + assert_eq!( + compared_values(&a.select(0), &reference, recipe(FieldRole::Tracer)).unwrap_err(), + ComparedValuesError::Unavailable(Unavailable::PublicationStage) + ); + for mutation in 0..7 { + let (mut run, mut steps) = fixture(0.01, false); + let mut role = FieldRole::Tracer; + let expected = match mutation { + 0 => { + run.solver = "future-profile/unknown".into(); + Unavailable::UnsupportedProfile + } + 1 => { + run.spacing[0] *= 2.0; + Unavailable::Geometry + } + 2 => { + steps[0].time = 0.02; + steps[0].dt = Some(0.02); + Unavailable::PhysicalTime + } + 3 => { + steps[0].fields.retain(|f| f.name != "tracer"); + Unavailable::MissingField + } + 4 => { + role = FieldRole::VelocityX; + field_mut(&mut steps[0], "velocity_x").association = "cell_center".into(); + Unavailable::FieldSchema + } + 5 => { + role = FieldRole::IntervalPressure; + put_f64(&mut steps[0], "interval_pressure", |_| 1.0); + Unavailable::NoncanonicalGauge + } + 6 => { + role = FieldRole::IntervalPressure; + field_mut(&mut steps[0], "interval_pressure").evaluation_time = Some(0.005); + Unavailable::FieldSchema + } + _ => unreachable!(), + }; + let b = Archive::new(run, &steps); + assert_eq!( + compared_values(&reference, &b.select(0), recipe(role)).unwrap_err(), + ComparedValuesError::Unavailable(expected), + "mutation {mutation}" + ); + } + let (run, mut steps) = fixture(0.01, false); + put_f64(&mut steps[0], "interval_pressure", |i| { + if i == 0 { 0.0 } else { f64::MAX } + }); + let a = Archive::new(run, &steps); + let (run, mut steps) = fixture(0.01, false); + put_f64(&mut steps[0], "interval_pressure", |i| { + if i == 0 { 0.0 } else { -f64::MAX } + }); + let b = Archive::new(run, &steps); + assert_eq!( + compared_values( + &a.select(0), + &b.select(0), + recipe(FieldRole::IntervalPressure) + ) + .unwrap_err(), + ComparedValuesError::Unavailable(Unavailable::ArithmeticRange) + ); +} diff --git a/tests/saved_comparison_ui_contract.rs b/tests/saved_comparison_ui_contract.rs new file mode 100644 index 00000000..071f1f17 --- /dev/null +++ b/tests/saved_comparison_ui_contract.rs @@ -0,0 +1,684 @@ +#![cfg(all(feature = "desktop", not(target_arch = "wasm32")))] +use rheon::{PressureImplementation, SnapshotStage, recording}; +#[path = "../src/bin/desktop/comparison.rs"] +mod comparison; +#[allow(dead_code)] +#[path = "../src/bin/desktop/comparison_archive.rs"] +mod comparison_archive; +#[allow(dead_code)] +#[path = "../src/recording/comparison.rs"] +mod native_comparison; +#[allow(dead_code)] +#[path = "../src/bin/desktop/run_sets.rs"] +mod run_sets; +use comparison::*; +use comparison_archive::*; +use eframe::egui; +use native_comparison::{FieldRole, PressurePolicy, Recipe}; +use recording::*; +use run_sets::*; +use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, +}; + +struct Directory(PathBuf); +impl Directory { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let p = std::env::temp_dir().join(format!( + "rheon-comparison-ui-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&p).unwrap(); + Self(p) + } +} +impl Drop for Directory { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } +} +fn make_spec(method: NativePressureMethod) -> NativeCarrierSpec { + NativeCarrierSpec { + counts_xyz: [2, 3, 4], + spacing_m: [0.25, 0.5, 0.75], + origin_m: [-1.0, 2.0, 3.0], + density_kg_m3: 1.0, + pressure_method: method, + tolerances: NativeTolerances { + relative_residual: 1e-10, + absolute_residual_m3_s2: 1e-12, + predicted_divergence_per_s: 1e-8, + actual_divergence_per_s: 1e-5, + max_courant: 1.0, + max_pressure_iterations: 2000, + }, + requested_dt_s: 0.003, + requested_steps: 3, + source: Some(SourceSpec { + lower_m: [-0.99, 2.1, 3.1], + upper_m: [-0.51, 2.9, 4.4], + tracer_rate_per_s: 2.0, + vertical_acceleration_m_s2: 1.0, + active_steps: 3, + }), + simulation_memory_limit_bytes: 64 * 1024 * 1024, + recording: ArchiveBudget { + max_database_bytes: 8 * 1024 * 1024, + max_record_bytes: 1024 * 1024, + max_records: 16, + }, + } +} +fn member(id: &str, method: NativePressureMethod) -> RunSetMember { + RunSetMember { + member_id: id.into(), + spec: FrozenSpec::new(make_spec(method)).unwrap(), + differences_from_baseline: if method == NativePressureMethod::JacobiPcgV1 { + vec![] + } else { + vec![SpecDifference::PressureMethod] + }, + attempt: Some(RunAttempt { + attempt_id: format!("attempt-{id}"), + archive_relative_path: format!("{id}.sqlite"), + expected_run_id: if method == NativePressureMethod::JacobiPcgV1 { + 41 + } else { + 42 + }, + expected_source_revision: "actual-native-page-test-source".into(), + finalized: None, + }), + } +} +fn produce(root: &Path, m: &RunSetMember, mode: &str) { + let a = m.attempt.as_ref().unwrap(); + let s = &m.spec.spec; + let mut sim = s.create_simulation().unwrap(); + let run = bound_native_run( + &m.spec, + &sim, + rheon::SessionIdentity { + run_id: a.expected_run_id, + source_revision: a.expected_source_revision.clone(), + }, + "native page fixture; actual numerical owner", + ) + .unwrap(); + let mut writer = StepRecorder::create( + &root.join(&a.archive_relative_path), + run, + s.recording.limits(), + ) + .unwrap(); + writer + .append(&carrier_publication(&sim, 0, s.recording.max_record_bytes).unwrap()) + .unwrap(); + if mode == "active" { + return; + } + for i in 0..s.requested_steps { + sim.step(s.requested_dt_s, s.source_at(i), |_| false) + .unwrap(); + if mode != "tail" { + writer + .append(&carrier_publication(&sim, i + 1, s.recording.max_record_bytes).unwrap()) + .unwrap(); + } + } + writer + .finish( + if mode == "tail" { + "failed" + } else { + "completed" + }, + if mode == "changed" { + "changed terminal receipt" + } else { + "actual accepted native fixture" + }, + Some(sim.state().generation), + ) + .unwrap(); +} +fn fixture(root: &Path) -> RunSetRevision { + let mut set = RunSetRevision { + schema_version: 1, + set_id: "native-page-fixture".into(), + parent_sha256: None, + baseline_member: "jacobi".into(), + members: vec![ + member("jacobi", NativePressureMethod::JacobiPcgV1), + member("sgs", NativePressureMethod::SymmetricGaussSeidelPcgV1), + ], + }; + for m in &mut set.members { + produce(root, m, "completed"); + m.attempt + .as_mut() + .unwrap() + .pin_closed(root, &m.spec) + .unwrap(); + } + set.save_new(&root.join("set.json")).unwrap(); + set +} +struct Harness { + ctx: egui::Context, + page: SavedComparisonPage, + time: f64, +} +impl Harness { + fn new(root: &Path) -> Self { + let mut page = SavedComparisonPage::default(); + page.set_location(root.to_string_lossy(), "set.json"); + Self { + ctx: egui::Context::default(), + page, + time: 0.0, + } + } + fn frame(&mut self, events: Vec) -> egui::FullOutput { + self.time += 0.016; + self.ctx.run( + egui::RawInput { + time: Some(self.time), + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1600.0, 2000.0), + )), + events, + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| self.page.show(ui)); + }, + ) + } + fn at(&mut self, pos: egui::Pos2) -> egui::FullOutput { + self.frame(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ]); + self.frame(vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]) + } + fn click(&mut self, id: &str) -> egui::FullOutput { + self.frame(vec![]); + let pos = self + .page + .control_rect(id) + .unwrap_or_else(|| panic!("missing {id}")) + .center(); + self.at(pos) + } + fn text(&mut self, id: &str, text: &str) { + self.click(id); + let modifiers = egui::Modifiers { + ctrl: true, + command: true, + ..Default::default() + }; + self.frame(vec![ + egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: true, + repeat: false, + modifiers, + }, + egui::Event::Text(text.into()), + ]); + } + fn choice(&mut self, id: &str, label: &str) { + self.click(id); + let out = self.frame(vec![]); + let pos = out + .shapes + .iter() + .rev() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.job.text == label => { + Some(t.pos + t.galley.size() * 0.5) + } + _ => None, + }) + .unwrap_or_else(|| panic!("missing choice {label}")); + self.at(pos); + } +} +fn text(out: &egui::FullOutput) -> String { + out.shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.job.text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +#[test] +fn real_headless_page_open_compute_export_and_unverified_verify_flow() { + let root = Directory::new(); + fixture(&root.0); + let mut h = Harness::new(&root.0); + h.click("open-manifest"); + assert_eq!(h.page.member_summaries().len(), 2); + assert!( + h.page + .member_summaries() + .iter() + .all(|m| m.availability == Availability::Ready + && m.recorded_generation == Some(3) + && m.accepted_generation == Some(3) + && m.unrecorded_tail == Some(false)) + ); + h.click("compute"); + assert_eq!(h.page.presentation(), Presentation::Verified); + let pair = h.page.selected_pair().unwrap(); + assert_eq!(pair.root, root.0); + assert_eq!(pair.pair.reference.ordinal, 0); + assert_eq!(pair.recipe.reference.member_id, "jacobi"); + let out = h.frame(vec![]); + let shown = text(&out); + assert!(shown.contains("Verified recorded-source comparison")); + assert!(shown.contains("actual endpoint")); + assert!(shown.contains("Common bounds")); + assert!(shown.contains("Relative L2 unavailable: reference norm is zero")); + h.text("export-path", "recipe.json"); + h.click("export-recipe"); + assert!(SavedRecipe::open(&root.0.join("recipe.json")).is_ok()); + h.text("export-path", "result.json"); + h.click("export-result"); + let original = std::fs::read(root.0.join("result.json")).unwrap(); + assert!(UnverifiedComparison::open(&root.0.join("result.json")).is_ok()); + h.click("export-result"); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert_eq!(std::fs::read(root.0.join("result.json")).unwrap(), original); + h.text("saved-path", "result.json"); + let out = h.click("open-result"); + assert_eq!(h.page.presentation(), Presentation::Unverified); + assert!(h.page.selected_pair().is_none()); + assert!(text(&out).contains("UNVERIFIED stored evidence")); + assert!( + h.page + .member_summaries() + .iter() + .all(|m| m.availability == Availability::Unchecked) + ); + h.click("verify"); + assert_eq!(h.page.presentation(), Presentation::Verified); + assert!(h.page.selected_pair().is_some()); + h.text("root", root.0.join("other").to_str().unwrap()); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert!(h.page.member_summaries().is_empty()); + assert!(h.page.selected_pair().is_none()); +} +#[test] +fn all_six_roles_gauge_and_tolerance_use_actual_ui_controls() { + let root = Directory::new(); + fixture(&root.0); + let mut h = Harness::new(&root.0); + h.click("open-manifest"); + for (label, role) in [ + ("Interval pressure", FieldRole::IntervalPressure), + ("Velocity X", FieldRole::VelocityX), + ("Velocity Y", FieldRole::VelocityY), + ("Velocity Z", FieldRole::VelocityZ), + ("Velocity divergence", FieldRole::VelocityDivergence), + ("Tracer", FieldRole::Tracer), + ] { + h.choice("role", label); + assert_eq!(h.page.presentation(), Presentation::Empty); + h.text("reference-start", "1"); + h.text("candidate-start", "1"); + h.click("compute"); + assert_eq!(h.page.selected_pair().unwrap().recipe.recipe.field, role); + } + h.choice("role", "Interval pressure"); + h.choice("gauge", "Remove mean offset (diagnostic)"); + h.text("tolerance", "0.000001"); + h.click("compute"); + let pair = h.page.selected_pair().unwrap(); + assert_eq!(pair.recipe.recipe.time_tolerance, 1e-6); + assert!( + pair.pair + .outcome + .as_ref() + .unwrap() + .gauge + .unwrap() + .diagnostic + ); + assert!(text(&h.frame(vec![])).contains("offset-aligned diagnostic")); +} +#[test] +fn invalid_ranges_tolerance_loads_and_path_edits_clear_stale_presentation() { + let root = Directory::new(); + fixture(&root.0); + let mut h = Harness::new(&root.0); + h.click("open-manifest"); + h.click("compute"); + h.text("reference-end", "128"); + assert_eq!(h.page.presentation(), Presentation::Empty); + h.click("compute"); + assert!(h.page.message().contains("at most 128")); + assert!(h.page.selected_pair().is_none()); + h.text("reference-end", "3"); + h.click("compute"); + assert_eq!(h.page.presentation(), Presentation::Verified); + h.text("tolerance", "NaN"); + h.click("compute"); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert!(h.page.message().contains("InvalidTolerance")); + h.text("tolerance", "0"); + h.click("compute"); + assert_eq!(h.page.presentation(), Presentation::Verified); + h.text("manifest", "missing.json"); + assert_eq!(h.page.presentation(), Presentation::Empty); + h.click("open-manifest"); + assert!(h.page.member_summaries().is_empty()); + h.text("manifest", "set.json"); + h.click("open-manifest"); + h.click("compute"); + h.text("saved-path", "missing-result.json"); + assert_eq!(h.page.presentation(), Presentation::Empty); + h.click("open-result"); + assert!(h.page.selected_pair().is_none()); +} +#[test] +fn saved_gapped_recipe_keeps_exact_ordinals_and_missing_source_verify_clears() { + let root = Directory::new(); + let set = fixture(&root.0); + let r = SavedRecipe::capture( + &root.0, + set, + "jacobi", + "sgs", + &[1, 3], + &[1, 3], + Recipe { + field: FieldRole::IntervalPressure, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + }, + ) + .unwrap(); + r.save_new(&root.0.join("gapped.json")).unwrap(); + r.compute(&root.0) + .unwrap() + .save_new(&root.0.join("result.json")) + .unwrap(); + let mut h = Harness::new(&root.0); + h.text("saved-path", "gapped.json"); + h.click("open-recipe"); + assert_eq!(h.page.presentation(), Presentation::Empty); + h.click("compute"); + let selected = h.page.selected_pair().unwrap(); + assert_eq!( + selected + .recipe + .reference + .selections + .iter() + .map(|s| s.ordinal) + .collect::>(), + vec![1, 3] + ); + h.text("saved-path", "result.json"); + h.click("open-result"); + std::fs::rename(root.0.join("jacobi.sqlite"), root.0.join("latest.sqlite")).unwrap(); + h.click("verify"); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert!(h.page.message().contains("missing exact archive")); + assert!(h.page.selected_pair().is_none()); +} +#[test] +fn manifest_source_states_and_real_unrecorded_accepted_tail_are_honest() { + let root = Directory::new(); + let mut set = fixture(&root.0); + for (id, mode) in [ + ("missing", "missing"), + ("incomplete", "active"), + ("unpinned", "completed"), + ("not-run", "none"), + ("unsupported", "future"), + ("invalid", "bad"), + ("tail", "tail"), + ] { + let mut m = member(id, NativePressureMethod::JacobiPcgV1); + match mode { + "missing" => { + m.attempt.as_mut().unwrap().finalized = + set.members[0].attempt.as_ref().unwrap().finalized.clone(); + } + "none" => m.attempt = None, + "bad" => { + std::fs::write(root.0.join("invalid.sqlite"), b"not sqlite").unwrap(); + } + "future" => { + produce(&root.0, &m, "completed"); + m.attempt + .as_mut() + .unwrap() + .pin_closed(&root.0, &m.spec) + .unwrap(); + rusqlite::Connection::open(root.0.join("unsupported.sqlite")) + .unwrap() + .pragma_update(None, "user_version", 99) + .unwrap(); + } + _ => { + produce(&root.0, &m, mode); + if mode == "tail" { + m.attempt + .as_mut() + .unwrap() + .pin_closed(&root.0, &m.spec) + .unwrap(); + } + } + } + set.members.push(m); + } + set.save_new(&root.0.join("states.json")).unwrap(); + let mut h = Harness::new(&root.0); + h.page.set_location(root.0.to_string_lossy(), "states.json"); + h.click("open-manifest"); + for (id, state) in [ + ("missing", Availability::Missing), + ("incomplete", Availability::Incomplete), + ("unpinned", Availability::Unpinned), + ("not-run", Availability::NotRun), + ("unsupported", Availability::Unsupported), + ("invalid", Availability::Invalid), + ] { + assert_eq!( + h.page + .member_summaries() + .iter() + .find(|s| s.member_id == id) + .unwrap() + .availability, + state, + "{id}" + ); + } + let tail = h + .page + .member_summaries() + .iter() + .find(|s| s.member_id == "tail") + .unwrap(); + assert_eq!(tail.availability, Availability::Ready); + assert_eq!(tail.recorded_count, Some(1)); + assert_eq!(tail.recorded_generation, Some(0)); + assert_eq!(tail.accepted_generation, Some(3)); + assert_eq!(tail.unrecorded_tail, Some(true)); + assert!(tail.detail.contains("failed")); +} + +#[test] +fn member_selection_and_absent_constructor_pressure_are_visible_without_fabricated_metrics() { + let root = Directory::new(); + fixture(&root.0); + let mut h = Harness::new(&root.0); + h.click("open-manifest"); + h.click("compute"); + h.choice("reference-member", "sgs"); + assert_eq!(h.page.presentation(), Presentation::Empty); + h.choice("candidate-member", "jacobi"); + h.click("compute"); + let pair = h.page.selected_pair().unwrap(); + assert_eq!(pair.recipe.reference.member_id, "sgs"); + assert_eq!(pair.recipe.candidate.member_id, "jacobi"); + h.choice("role", "Interval pressure"); + h.click("compute"); + let shown = text(&h.frame(vec![])); + assert!(shown.contains("Metric unavailable: recorded field is absent")); + assert_eq!( + h.page.selected_pair().unwrap().pair.outcome, + Err(native_comparison::Unavailable::MissingField) + ); + assert!(!shown.contains("RMSE")); +} +#[test] +fn missing_source_refreshes_availability_on_both_compute_and_verify_failures() { + let root = Directory::new(); + fixture(&root.0); + let mut h = Harness::new(&root.0); + h.click("open-manifest"); + h.click("compute"); + h.text("export-path", "result.json"); + h.click("export-result"); + std::fs::rename(root.0.join("jacobi.sqlite"), root.0.join("moved.sqlite")).unwrap(); + h.click("compute"); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert_eq!( + h.page.member_summaries()[0].availability, + Availability::Missing + ); + h.text("saved-path", "result.json"); + h.click("open-result"); + assert_eq!(h.page.presentation(), Presentation::Unverified); + h.click("verify"); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert_eq!( + h.page.member_summaries()[0].availability, + Availability::Missing + ); + h.text("saved-path", "result.json"); + h.click("open-result"); + assert_eq!(h.page.presentation(), Presentation::Unverified); + h.text("root", root.0.join("other-root").to_str().unwrap()); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert!(h.page.member_summaries().is_empty()); +} + +#[test] +fn explicit_selected_field_materialization_requires_verified_exact_sources() { + let root = Directory::new(); + let set = fixture(&root.0); + let recipe = SavedRecipe::capture( + &root.0, + set, + "jacobi", + "sgs", + &[1], + &[1], + Recipe { + field: FieldRole::VelocityX, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + }, + ) + .unwrap(); + recipe + .compute(&root.0) + .unwrap() + .save_new(&root.0.join("result.json")) + .unwrap(); + let mut h = Harness::new(&root.0); + assert!(h.page.load_selected_fields().unwrap().is_none()); + h.text("saved-path", "result.json"); + h.click("open-result"); + std::fs::rename( + root.0.join("jacobi.sqlite"), + root.0.join("unavailable.sqlite"), + ) + .unwrap(); + // Missing source is immaterial here: Unverified must not attempt source I/O. + assert!(h.page.load_selected_fields().unwrap().is_none()); + assert_eq!(h.page.presentation(), Presentation::Unverified); + std::fs::rename( + root.0.join("unavailable.sqlite"), + root.0.join("jacobi.sqlite"), + ) + .unwrap(); + h.click("verify"); + let data = h.page.load_selected_fields().unwrap().unwrap(); + assert_eq!( + data.sampling.association, + native_comparison::SampleAssociation::XFace + ); + assert_eq!(data.sampling.dimensions, [3, 3, 4]); + assert_eq!(data.reference.len(), 36); + assert_eq!(data.comparison, *h.page.selected_pair().unwrap().pair); + std::fs::rename(root.0.join("jacobi.sqlite"), root.0.join("changed.sqlite")).unwrap(); + assert!(matches!( + h.page.load_selected_fields(), + Err(SelectedFieldsError::Source(_)) + )); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert_eq!( + h.page.member_summaries()[0].availability, + Availability::Missing + ); + std::fs::rename(root.0.join("changed.sqlite"), root.0.join("jacobi.sqlite")).unwrap(); + h.click("open-result"); + h.click("verify"); + let candidate = h.page.selected_pair().unwrap().recipe.run_set.members[1].clone(); + std::fs::remove_file(root.0.join("sgs.sqlite")).unwrap(); + produce(&root.0, &candidate, "changed"); + assert!( + matches!(h.page.load_selected_fields(),Err(SelectedFieldsError::Source(reason)) if reason.contains("finalized archive changed")) + ); + assert_eq!(h.page.presentation(), Presentation::Empty); + assert_eq!( + h.page.member_summaries()[1].availability, + Availability::Invalid + ); +} +#[test] +fn unavailable_native_fields_remain_verified_analysis_without_materialized_values() { + let root = Directory::new(); + fixture(&root.0); + let mut h = Harness::new(&root.0); + h.click("open-manifest"); + h.choice("role", "Interval pressure"); + h.click("compute"); + assert!(matches!( + h.page.load_selected_fields(), + Err(SelectedFieldsError::Comparison( + native_comparison::ComparedValuesError::Unavailable( + native_comparison::Unavailable::MissingField + ) + )) + )); + assert_eq!(h.page.presentation(), Presentation::Verified); + assert!(h.page.selected_pair().is_some()); +} diff --git a/tests/saved_run_set_contract.rs b/tests/saved_run_set_contract.rs new file mode 100644 index 00000000..89368117 --- /dev/null +++ b/tests/saved_run_set_contract.rs @@ -0,0 +1,973 @@ +#![cfg(all(feature = "desktop", not(target_arch = "wasm32")))] +#[path = "../src/bin/desktop/run_sets.rs"] +mod run_sets; + +use rheon::{SessionIdentity, recording::*}; +use run_sets::*; +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::atomic::{AtomicUsize, Ordering}, +}; + +fn directory() -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "rheon-saved-sets-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + path +} +fn spec(method: NativePressureMethod) -> NativeCarrierSpec { + NativeCarrierSpec { + counts_xyz: [3, 4, 5], + spacing_m: [0.2, 0.25, 0.3], + origin_m: [-2.0, 3.0, 5.0], + density_kg_m3: 1.0, + pressure_method: method, + tolerances: NativeTolerances { + relative_residual: 1e-10, + absolute_residual_m3_s2: 1e-12, + predicted_divergence_per_s: 1e-8, + actual_divergence_per_s: 1e-5, + max_courant: 1.0, + max_pressure_iterations: 2000, + }, + requested_dt_s: 0.003, + requested_steps: 3, + source: Some(SourceSpec { + lower_m: [-1.9, 3.1, 5.1], + upper_m: [-1.6, 3.7, 6.1], + tracer_rate_per_s: 2.0, + vertical_acceleration_m_s2: 1.0, + active_steps: 3, + }), + simulation_memory_limit_bytes: 64 * 1024 * 1024, + recording: ArchiveBudget { + max_database_bytes: 8 * 1024 * 1024, + max_record_bytes: 1024 * 1024, + max_records: 16, + }, + } +} +fn attempt(method: NativePressureMethod) -> RunAttempt { + let method = method.implementation().id(); + RunAttempt { + attempt_id: format!("attempt-{method}"), + archive_relative_path: format!("{method}.sqlite"), + expected_run_id: 77, + expected_source_revision: "actual-native-test-source".into(), + finalized: None, + } +} +fn member(method: NativePressureMethod) -> RunSetMember { + RunSetMember { + member_id: method.implementation().id().into(), + spec: FrozenSpec::new(spec(method)).unwrap(), + differences_from_baseline: if method == NativePressureMethod::JacobiPcgV1 { + vec![] + } else { + vec![SpecDifference::PressureMethod] + }, + attempt: Some(attempt(method)), + } +} +fn set() -> RunSetRevision { + RunSetRevision { + schema_version: 1, + set_id: "two-real-native-variants".into(), + parent_sha256: None, + baseline_member: "jacobi-pcg-v1".into(), + members: vec![ + member(NativePressureMethod::JacobiPcgV1), + member(NativePressureMethod::SymmetricGaussSeidelPcgV1), + ], + } +} +/// This test host drives existing numerical and recording owners directly. It +/// is not a second application worker, provider runtime or GUI acceptance test. +fn produce(root: &Path, member: &RunSetMember, finish: bool) -> Vec { + produce_with_metadata(root, member, finish, false) +} +fn produce_with_metadata( + root: &Path, + member: &RunSetMember, + finish: bool, + historical_metadata: bool, +) -> Vec { + let a = member.attempt.as_ref().unwrap(); + let s = &member.spec.spec; + let mut simulation = s.create_simulation().unwrap(); + let mut run = bound_native_run( + &member.spec, + &simulation, + SessionIdentity { + run_id: a.expected_run_id, + source_revision: a.expected_source_revision.clone(), + }, + "native contract host; no model loaded", + ) + .unwrap(); + if historical_metadata { + run.solver = format!( + "rheon-carrier-full-box-v1/archived-build/{}", + s.pressure_method.implementation().id() + ); + run.configuration = "opaque historical producer Debug rendering, never executed".into(); + } + let limits = s.recording.limits(); + let mut writer = + StepRecorder::create(&root.join(&a.archive_relative_path), run, limits).unwrap(); + let mut publications = + vec![carrier_publication(&simulation, 0, limits.max_record_bytes).unwrap()]; + writer.append(&publications[0]).unwrap(); + for interval in 0..s.requested_steps { + simulation + .step(s.requested_dt_s, s.source_at(interval), |_| false) + .unwrap(); + let publication = + carrier_publication(&simulation, interval + 1, limits.max_record_bytes).unwrap(); + writer.append(&publication).unwrap(); + publications.push(publication); + } + if finish { + writer + .finish( + "completed", + "all requested native intervals", + Some(simulation.state().generation), + ) + .unwrap(); + } + publications +} +fn ready(root: &Path, member: &RunSetMember) -> RecordingReader { + match open_member(root, member) { + MemberState::Ready(reader) => *reader, + _ => panic!("not a pinned ready member"), + } +} + +#[test] +fn two_real_native_variants_close_and_cold_reopen_exact_bits() { + let root = directory(); + let mut revision = set(); + for member in &mut revision.members { + let expected = produce(&root, member, true); + member + .attempt + .as_mut() + .unwrap() + .pin_closed(&root, &member.spec) + .unwrap(); + let reader = ready(&root, member); + assert_eq!(reader.count().unwrap(), 4); + assert_eq!(reader.status().unwrap().outcome, "completed"); + for publication in expected { + let actual = reader.select(publication.ordinal).unwrap(); + assert_eq!(actual.generation, publication.generation); + assert_eq!(actual.time.to_bits(), publication.time.to_bits()); + assert_eq!( + actual.dt.map(f64::to_bits), + publication.dt.map(f64::to_bits) + ); + assert_eq!(actual.fields, publication.fields); + } + } + revision.members[0] + .spec + .save_new(&root.join("spec.json")) + .unwrap(); + assert_eq!( + FrozenSpec::open(&root.join("spec.json")).unwrap(), + revision.members[0].spec + ); + revision.save_new(&root.join("set.json")).unwrap(); + assert_eq!( + RunSetRevision::open(&root.join("set.json")).unwrap(), + revision + ); + // Run a distinct reader process after both native producers and SQLite + // writers have been dropped; this process never constructs Simulation. + let output = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "cold_reader_child", "--nocapture"]) + .env("RHEON_SAVED_SET_CHILD", &root) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout) + .contains("fresh-process exact native bytes verified") + ); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn cold_reader_child() { + let Some(root) = std::env::var_os("RHEON_SAVED_SET_CHILD") else { + return; + }; + let root = PathBuf::from(root); + let revision = RunSetRevision::open(&root.join("set.json")).unwrap(); + for member in &revision.members { + let reader = ready(&root, member); + let publication = reader.select(1).unwrap(); + let native = RecordedCarrier::decode(reader.run(), &publication).unwrap(); + assert_eq!(publication.time.to_bits(), 0.003f64.to_bits()); + assert_eq!(publication.fields.len(), 5); + assert_eq!(native.state().generation, 1); + // Independently read the existing SQLite BLOB using its public wire + // contract; exact native bytes must agree with the selected decoder. + let path = root.join(&member.attempt.as_ref().unwrap().archive_relative_path); + let connection = rusqlite::Connection::open(path).unwrap(); + let raw: Vec = connection + .query_row( + "SELECT data FROM fields WHERE ordinal=1 AND name='velocity_y'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(publication.field("velocity_y").unwrap().data, raw); + let native_bits: Vec = native + .state() + .y + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect(); + assert_eq!(native_bits, raw); + } + println!("fresh-process exact native bytes verified"); +} +#[test] +fn closed_producer_child() { + let Some(root) = std::env::var_os("RHEON_SAVED_SET_PRODUCER") else { + return; + }; + let root = PathBuf::from(root); + let mut revision = set(); + for member in &mut revision.members { + produce(&root, member, true); + member + .attempt + .as_mut() + .unwrap() + .pin_closed(&root, &member.spec) + .unwrap(); + } + revision.save_new(&root.join("set.json")).unwrap(); + println!("two native archives finalized by producer process"); +} +#[test] +fn producer_process_exits_before_fresh_reader_starts() { + let root = directory(); + let producer = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "closed_producer_child", "--nocapture"]) + .env("RHEON_SAVED_SET_PRODUCER", &root) + .output() + .unwrap(); + assert!( + producer.status.success(), + "{}\n{}", + String::from_utf8_lossy(&producer.stdout), + String::from_utf8_lossy(&producer.stderr) + ); + assert!( + String::from_utf8_lossy(&producer.stdout) + .contains("two native archives finalized by producer process") + ); + let reader = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "cold_reader_child", "--nocapture"]) + .env("RHEON_SAVED_SET_CHILD", &root) + .output() + .unwrap(); + assert!( + reader.status.success(), + "{}\n{}", + String::from_utf8_lossy(&reader.stdout), + String::from_utf8_lossy(&reader.stderr) + ); + assert!( + String::from_utf8_lossy(&reader.stdout) + .contains("fresh-process exact native bytes verified") + ); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn full_intent_binding_rejects_changed_source_time_and_controls_without_parsing_debug() { + let root = directory(); + let mut m = member(NativePressureMethod::JacobiPcgV1); + produce_with_metadata(&root, &m, true, true); + m.attempt + .as_mut() + .unwrap() + .pin_closed(&root, &m.spec) + .unwrap(); + assert!(ready(&root, &m).run().configuration.contains("historical")); + for change in 0..3 { + let mut altered = m.clone(); + match change { + 0 => altered.spec.spec.source.as_mut().unwrap().tracer_rate_per_s = 4.0, + 1 => altered.spec.spec.requested_dt_s = 0.002, + _ => altered.spec.spec.tolerances.relative_residual = 1e-8, + } + altered.spec = FrozenSpec::new(altered.spec.spec).unwrap(); + assert!(matches!( + open_member(&root, &altered), + MemberState::Invalid(RunSetError::Integrity("archive/spec binding")) + )) + } + let mut simulation = m.spec.spec.create_simulation().unwrap(); + simulation + .step(m.spec.spec.requested_dt_s, None, |_| false) + .unwrap(); + assert!(matches!( + bound_native_run( + &m.spec, + &simulation, + SessionIdentity { + run_id: 77, + source_revision: "test".into() + }, + "test" + ), + Err(RunSetError::Invalid("native constructor/spec binding")) + )); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn immutable_revisions_refuse_overwrite_and_keep_old_members() { + let root = directory(); + let first = set(); + let first_hash = first.sha256().unwrap(); + first.save_new(&root.join("first.json")).unwrap(); + let old_bytes = std::fs::read(root.join("first.json")).unwrap(); + let mut second = first.clone(); + second.parent_sha256 = Some(first_hash); + second.members[1].spec.spec.requested_dt_s = 0.001; + second.members[1].spec = FrozenSpec::new(second.members[1].spec.spec.clone()).unwrap(); + second.members[1] + .differences_from_baseline + .push(SpecDifference::TimePolicy); + assert!(second.save_new(&root.join("first.json")).is_err()); + assert_eq!(std::fs::read(root.join("first.json")).unwrap(), old_bytes); + second.save_new(&root.join("second.json")).unwrap(); + assert_eq!( + RunSetRevision::open(&root.join("first.json")).unwrap(), + first + ); + assert_ne!(second.sha256().unwrap(), first_hash); + assert!(matches!( + open_member(&root, &second.members[1]), + MemberState::Missing + )); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn not_run_missing_incomplete_and_unpinned_are_distinct() { + let root = directory(); + let mut member = member(NativePressureMethod::JacobiPcgV1); + let reference = member.attempt.take(); + assert!(matches!(open_member(&root, &member), MemberState::NotRun)); + member.attempt = reference; + assert!(matches!(open_member(&root, &member), MemberState::Missing)); + produce(&root, &member, false); + match open_member(&root, &member) { + MemberState::Incomplete { + recorded_count, + status, + } => { + assert_eq!(recorded_count, 4); + assert_eq!(status.outcome, "active"); + } + _ => panic!("active archive was not incomplete"), + } + assert!(matches!( + member + .attempt + .as_mut() + .unwrap() + .pin_closed(&root, &member.spec), + Err(RunSetError::Invalid("archive is not terminal")) + )); + let other_root = directory(); + produce(&other_root, &member, true); + match open_member(&other_root, &member) { + MemberState::Unpinned { + recorded_count, + status, + } => { + assert_eq!(recorded_count, 4); + assert_eq!(status.outcome, "completed"); + } + _ => panic!("unbound terminal archive silently promoted"), + } + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(other_root).unwrap(); +} +#[test] +fn corrupt_changed_and_unsupported_members_never_substitute_live_data() { + let root = directory(); + let mut m = member(NativePressureMethod::JacobiPcgV1); + produce(&root, &m, true); + m.attempt + .as_mut() + .unwrap() + .pin_closed(&root, &m.spec) + .unwrap(); + let path = root.join(&m.attempt.as_ref().unwrap().archive_relative_path); + let original = std::fs::read(&path).unwrap(); + let c = rusqlite::Connection::open(&path).unwrap(); + c.execute( + "UPDATE fields SET data=zeroblob(length(data)) WHERE ordinal=1 AND name='velocity_y'", + [], + ) + .unwrap(); + drop(c); + assert!(matches!( + open_member(&root, &m), + MemberState::Invalid(RunSetError::Integrity("finalized archive changed")) + )); + std::fs::write(&path, &original).unwrap(); + let c = rusqlite::Connection::open(&path).unwrap(); + c.pragma_update(None, "user_version", 99).unwrap(); + drop(c); + assert!(matches!( + open_member(&root, &m), + MemberState::Unsupported("archive version") + )); + std::fs::write(&path, &original).unwrap(); + m.attempt.as_mut().unwrap().expected_run_id += 1; + assert!(matches!( + open_member(&root, &m), + MemberState::Invalid(RunSetError::Integrity("archive/spec binding")) + )); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn bounded_strict_manifests_reject_versions_keys_duplicate_keys_and_mutation() { + let root = directory(); + let path = root.join("spec.json"); + let frozen = FrozenSpec::new(spec(NativePressureMethod::JacobiPcgV1)).unwrap(); + frozen.save_new(&path).unwrap(); + let original = std::fs::read_to_string(&path).unwrap(); + std::fs::write( + &path, + original.replacen("\"schema_version\":1", "\"schema_version\":99", 1), + ) + .unwrap(); + assert!(matches!( + FrozenSpec::open(&path), + Err(RunSetError::Unsupported("manifest version")) + )); + std::fs::write( + &path, + original.replacen( + "\"schema_version\":1", + "\"schema_version\":1,\"surprise\":true", + 1, + ), + ) + .unwrap(); + assert!(matches!( + FrozenSpec::open(&path), + Err(RunSetError::Codec(_)) + )); + std::fs::write( + &path, + original.replacen( + "\"requested_steps\":3", + "\"requested_steps\":3,\"requested_steps\":4", + 1, + ), + ) + .unwrap(); + assert!(matches!( + FrozenSpec::open(&path), + Err(RunSetError::Codec(_)) + )); + std::fs::write( + &path, + original.replacen("\"requested_steps\":3", "\"requested_steps\":4", 1), + ) + .unwrap(); + assert!(matches!( + FrozenSpec::open(&path), + Err(RunSetError::Integrity("spec digest")) + )); + std::fs::write(&path, vec![b' '; 1024 * 1024 + 1]).unwrap(); + assert!(matches!( + FrozenSpec::open(&path), + Err(RunSetError::Invalid("manifest byte limit")) + )); + let mut revision = set(); + revision.members[1].differences_from_baseline.clear(); + assert!(matches!( + revision.validate(), + Err(RunSetError::Invalid("declared differences")) + )); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn spec_and_archive_paths_reject_invalid_boundaries() { + let root = directory(); + let mut s = spec(NativePressureMethod::JacobiPcgV1); + s.source.as_mut().unwrap().upper_m[0] = 1e100; + assert!(matches!( + s.validate(), + Err(RunSetError::Invalid("spec source")) + )); + for relative in [ + "../outside.sqlite", + "/tmp/elsewhere.sqlite", + "nested/../run.sqlite", + "a//b.sqlite", + "a\\b.sqlite", + "C:run.sqlite", + ] { + let mut m = member(NativePressureMethod::JacobiPcgV1); + m.attempt.as_mut().unwrap().archive_relative_path = relative.into(); + assert!(matches!( + open_member(&root, &m), + MemberState::Invalid(RunSetError::Invalid("archive relative path")) + )) + } + #[cfg(unix)] + { + let m = member(NativePressureMethod::JacobiPcgV1); + let elsewhere = directory(); + produce(&elsewhere, &m, true); + std::os::unix::fs::symlink( + elsewhere.join(&m.attempt.as_ref().unwrap().archive_relative_path), + root.join(&m.attempt.as_ref().unwrap().archive_relative_path), + ) + .unwrap(); + assert!(matches!( + open_member(&root, &m), + MemberState::Invalid(RunSetError::Invalid("symlink archive component")) + )); + std::fs::remove_dir_all(elsewhere).unwrap(); + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn native_binding_checks_actual_budgets_fields_horizons_and_unrecorded_tail() { + for case in 0..5 { + let root = directory(); + let mut m = member(NativePressureMethod::JacobiPcgV1); + let mut publications = produce(&root, &m, true); + let path = root.join(&m.attempt.as_ref().unwrap().archive_relative_path); + let reader = RecordingReader::open(&path, m.spec.spec.recording.limits()).unwrap(); + let run = reader.run().clone(); + drop(reader); + std::fs::rename(&path, root.join("original.sqlite")).unwrap(); + let mut limits = m.spec.spec.recording.limits(); + let mut outcome = "completed"; + let mut accepted = Some(3); + match case { + 0 => limits.max_records = 8, + 1 => { + publications[0].fields.pop(); + } + 2 => { + outcome = "failed"; + accepted = Some(4); + } + 3 => { + publications.truncate(2); + outcome = "failed"; + } + _ => { + publications.clear(); + outcome = "failed"; + accepted = None; + } + } + let mut writer = StepRecorder::create(&path, run, limits).unwrap(); + for publication in &publications { + writer.append(publication).unwrap(); + } + writer + .finish( + outcome, + "declared failed accepted tail where applicable", + accepted, + ) + .unwrap(); + drop(writer); + let result = m.attempt.as_mut().unwrap().pin_closed(&root, &m.spec); + match case { + 0 => assert!(matches!( + result, + Err(RunSetError::Integrity("archive recording budget")) + )), + 1 => assert!(matches!( + result, + Err(RunSetError::Recording(RecordingError::Invalid( + "carrier field inventory" + ))) + )), + 2 => assert!(matches!( + result, + Err(RunSetError::Integrity("native publication horizon")) + )), + 3 => { + result.unwrap(); + let reader = ready(&root, &m); + assert_eq!(reader.count().unwrap(), 2); + assert_eq!(reader.status().unwrap().accepted_generation, Some(3)); + assert_eq!(reader.status().unwrap().outcome, "failed"); + assert!(matches!(reader.select(2), Err(RecordingError::Missing(2)))); + } + _ => { + result.unwrap(); + let reader = ready(&root, &m); + assert_eq!(reader.count().unwrap(), 0); + assert_eq!(reader.status().unwrap().outcome, "failed"); + } + } + std::fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn concurrent_manifest_publication_has_one_winner_and_no_overwrite() { + let root = directory(); + let target = root.join("one.json"); + let first = FrozenSpec::new(spec(NativePressureMethod::JacobiPcgV1)).unwrap(); + let second = FrozenSpec::new(spec(NativePressureMethod::SymmetricGaussSeidelPcgV1)).unwrap(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let tasks = [first.clone(), second.clone()] + .into_iter() + .map(|frozen| { + let barrier = barrier.clone(); + let target = target.clone(); + std::thread::spawn(move || { + barrier.wait(); + frozen.save_new(&target) + }) + }) + .collect::>(); + let results = tasks + .into_iter() + .map(|task| task.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + assert_eq!(results.iter().filter(|r|matches!(r,Err(RunSetError::Io(e)) if e.kind()==std::io::ErrorKind::AlreadyExists)).count(),1); + let saved = FrozenSpec::open(&target).unwrap(); + assert!(saved == first || saved == second); + std::fs::remove_dir_all(root).unwrap(); +} + +fn fresh_native_writer(root: &Path, m: &RunSetMember) -> (rheon::Simulation, StepRecorder) { + let a = m.attempt.as_ref().unwrap(); + let simulation = m.spec.spec.create_simulation().unwrap(); + let run = bound_native_run( + &m.spec, + &simulation, + SessionIdentity { + run_id: a.expected_run_id, + source_revision: a.expected_source_revision.clone(), + }, + "real native acceptance host", + ) + .unwrap(); + let limits = m.spec.spec.recording.limits(); + let mut writer = + StepRecorder::create(&root.join(&a.archive_relative_path), run, limits).unwrap(); + writer + .append(&carrier_publication(&simulation, 0, limits.max_record_bytes).unwrap()) + .unwrap(); + (simulation, writer) +} + +#[test] +fn accepted_native_capacity_failure_cold_reopens_honest_unrecorded_tail() { + let root = directory(); + let mut m = member(NativePressureMethod::JacobiPcgV1); + m.spec.spec.recording.max_records = 2; + m.spec = FrozenSpec::new(m.spec.spec).unwrap(); + let (mut simulation, mut writer) = fresh_native_writer(&root, &m); + let s = &m.spec.spec; + for interval in 0..2 { + simulation + .step(s.requested_dt_s, s.source_at(interval), |_| false) + .unwrap(); + let publication = + carrier_publication(&simulation, interval + 1, s.recording.max_record_bytes).unwrap(); + let result = writer.append(&publication); + if interval == 0 { + result.unwrap(); + } else { + assert!(matches!(result, Err(RecordingError::Limit("record count")))); + assert_eq!( + simulation.state().generation, + 2, + "recording cannot roll physics back" + ); + assert!(matches!( + writer.append(&publication), + Err(RecordingError::Closed) + )); + } + } + assert!(matches!( + writer.finish( + "completed", + "cannot conceal accepted unrecorded tail", + Some(2) + ), + Err(RecordingError::Invalid( + "completion must cover accepted tail" + )) + )); + writer + .finish( + "failed", + "accepted generation2 is unrecorded after capacity", + Some(2), + ) + .unwrap(); + drop(writer); + drop(simulation); + m.attempt + .as_mut() + .unwrap() + .pin_closed(&root, &m.spec) + .unwrap(); + let revision = RunSetRevision { + schema_version: 1, + set_id: "capacity-unrecorded-tail".into(), + parent_sha256: None, + baseline_member: m.member_id.clone(), + members: vec![m], + }; + revision.save_new(&root.join("failed-set.json")).unwrap(); + let reader = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "failed_tail_reader_child", "--nocapture"]) + .env("RHEON_FAILED_TAIL_READER", &root) + .output() + .unwrap(); + assert!( + reader.status.success(), + "{}\n{}", + String::from_utf8_lossy(&reader.stdout), + String::from_utf8_lossy(&reader.stderr) + ); + assert!( + String::from_utf8_lossy(&reader.stdout) + .contains("accepted unrecorded native tail retained") + ); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn failed_tail_reader_child() { + let Some(root) = std::env::var_os("RHEON_FAILED_TAIL_READER") else { + return; + }; + let root = PathBuf::from(root); + let revision = RunSetRevision::open(&root.join("failed-set.json")).unwrap(); + let reader = ready(&root, &revision.members[0]); + assert_eq!(reader.status().unwrap().outcome, "failed"); + assert_eq!(reader.status().unwrap().accepted_generation, Some(2)); + assert_eq!(reader.count().unwrap(), 2); + let selected = reader.select(1).unwrap(); + assert_eq!(selected.generation, 1); + RecordedCarrier::decode(reader.run(), &selected).unwrap(); + assert!( + selected.field("temperature").is_none(), + "unknown fields cannot become zeros" + ); + assert!(matches!(reader.select(2), Err(RecordingError::Missing(2)))); + println!("accepted unrecorded native tail retained"); +} +#[test] +fn cold_selected_member_is_stable_while_another_native_track_advances() { + let root = directory(); + let mut first = member(NativePressureMethod::JacobiPcgV1); + produce(&root, &first, true); + first + .attempt + .as_mut() + .unwrap() + .pin_closed(&root, &first.spec) + .unwrap(); + let first_reader = ready(&root, &first); + let selected = first_reader.select(1).unwrap(); + let mut second = member(NativePressureMethod::SymmetricGaussSeidelPcgV1); + let (mut simulation, mut writer) = fresh_native_writer(&root, &second); + let original = RunSetRevision { + schema_version: 1, + set_id: "independent-native-tracks".into(), + parent_sha256: None, + baseline_member: first.member_id.clone(), + members: vec![first.clone(), second.clone()], + }; + original.save_new(&root.join("set-active.json")).unwrap(); + for interval in 0..second.spec.spec.requested_steps { + let s = &second.spec.spec; + simulation + .step(s.requested_dt_s, s.source_at(interval), |_| false) + .unwrap(); + writer + .append( + &carrier_publication(&simulation, interval + 1, s.recording.max_record_bytes) + .unwrap(), + ) + .unwrap(); + let stable = first_reader.select(1).unwrap(); + assert_eq!(stable.generation, selected.generation); + assert_eq!(stable.time.to_bits(), selected.time.to_bits()); + assert_eq!(stable.fields, selected.fields); + let reopened = RunSetRevision::open(&root.join("set-active.json")).unwrap(); + match open_member(&root, &reopened.members[1]) { + MemberState::Incomplete { + recorded_count, + status, + } => { + assert_eq!(recorded_count, interval + 2); + assert_eq!(status.outcome, "active"); + } + _ => panic!("advancing native track silently became a completed selection"), + } + } + writer + .finish("completed", "three native intervals", Some(3)) + .unwrap(); + drop(writer); + drop(simulation); + second + .attempt + .as_mut() + .unwrap() + .pin_closed(&root, &second.spec) + .unwrap(); + let mut closed = original.clone(); + closed.parent_sha256 = Some(original.sha256().unwrap()); + closed.members[1] = second; + closed.save_new(&root.join("set-closed.json")).unwrap(); + let old = RunSetRevision::open(&root.join("set-active.json")).unwrap(); + assert!( + matches!( + open_member(&root, &old.members[1]), + MemberState::Unpinned { .. } + ), + "old revision cannot acquire a new final artifact binding" + ); + let completed = RunSetRevision::open(&root.join("set-closed.json")).unwrap(); + assert_eq!( + ready(&root, &completed.members[1]) + .select(1) + .unwrap() + .generation, + 1 + ); + assert_eq!(first_reader.select(1).unwrap().fields, selected.fields); + drop(first_reader); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn real_native_terminal_busy_stays_incomplete_without_overwrite_recovery() { + let root = directory(); + let mut m = member(NativePressureMethod::JacobiPcgV1); + let (mut simulation, mut writer) = fresh_native_writer(&root, &m); + simulation + .step(m.spec.spec.requested_dt_s, m.spec.spec.source_at(0), |_| { + false + }) + .unwrap(); + writer + .append( + &carrier_publication(&simulation, 1, m.spec.spec.recording.max_record_bytes).unwrap(), + ) + .unwrap(); + let path = root.join(&m.attempt.as_ref().unwrap().archive_relative_path); + let lock = rusqlite::Connection::open(&path).unwrap(); + lock.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!( + matches!(writer.finish("cancelled","real writer collision",Some(1)),Err(RecordingError::Sqlite(error)) if error.sqlite_error_code()==Some(rusqlite::ErrorCode::DatabaseBusy)) + ); + lock.execute_batch("ROLLBACK").unwrap(); + drop(lock); + assert!(matches!( + writer.finish("cancelled", "cannot retry uncertain terminal", Some(1)), + Err(RecordingError::Closed) + )); + drop(writer); + drop(simulation); + let exact = std::fs::read(&path).unwrap(); + match open_member(&root, &m) { + MemberState::Incomplete { + recorded_count, + status, + } => { + assert_eq!(recorded_count, 2); + assert_eq!(status.outcome, "active"); + } + _ => panic!("failed terminal status inferred completion from valid tail"), + } + assert!(matches!( + m.attempt.as_mut().unwrap().pin_closed(&root, &m.spec), + Err(RunSetError::Invalid("archive is not terminal")) + )); + assert_eq!( + std::fs::read(&path).unwrap(), + exact, + "reconciliation cannot overwrite or delete native evidence" + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn capture_actual_constructor_feeds_existing_native_producer_without_debug_configuration() { + for method in [ + NativePressureMethod::JacobiPcgV1, + NativePressureMethod::SymmetricGaussSeidelPcgV1, + ] { + let expected = spec(method); + let mut simulation = expected.create_simulation().unwrap(); + let captured = FrozenSpec::capture_constructor( + &simulation, + expected.requested_dt_s, + expected.requested_steps, + expected.source.clone(), + expected.recording.limits(), + ) + .unwrap(); + assert_eq!(captured.spec, expected); + let run = bound_native_run( + &captured, + &simulation, + SessionIdentity { + run_id: 9, + source_revision: "existing-native-owner".into(), + }, + "captured actual constructor", + ) + .unwrap(); + assert_eq!(run.counts, expected.counts_xyz); + assert_eq!( + run.solver.rsplit('/').next(), + Some(method.implementation().id()) + ); + simulation + .step(expected.requested_dt_s, expected.source_at(0), |_| false) + .unwrap(); + assert!(matches!( + FrozenSpec::capture_constructor( + &simulation, + expected.requested_dt_s, + expected.requested_steps, + expected.source.clone(), + expected.recording.limits() + ), + Err(RunSetError::Invalid("native intent requires constructor")) + )); + } +} diff --git a/tools/verify_recording_comparison.py b/tools/verify_recording_comparison.py new file mode 100644 index 00000000..6da71d7f --- /dev/null +++ b/tools/verify_recording_comparison.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Independent native-bit oracle for bounded M3 fixtures. + +Reads SQLite directly with Python's standard library. Derives divergence by +coordinate/face lookups and reductions with math.fsum, independently of Rust's +streaming hypot and index arithmetic. This checks analytical test fixtures and +analysis-v1 selection identity, not archive acceptance or physical accuracy. +""" +import argparse +import hashlib +import math +import sqlite3 +import struct +from pathlib import Path + + +def u64(value): + return struct.pack(" 0 + if args.remove_mean_offset: + assert args.field == "interval_pressure" + offset = math.fsum(candidate-reference for reference, candidate in zip(x, y, strict=True)) / len(x) + y = [value-offset for value in y] + squares = [(candidate-reference)**2 for reference, candidate in zip(x, y, strict=True)] + l2 = math.sqrt(math.fsum(squares)) + norm = math.sqrt(math.fsum(v*v for v in x)) + results = [l2, math.sqrt(math.fsum(squares)/len(x)), max(abs(candidate-reference) for reference, candidate in zip(x, y, strict=True)), None if norm == 0 else l2/norm] + expected = [None if v == "undefined" else float(v) for v in args.expect] + reference_bound, reference_max = roundoff_bounds(a, args.field) + candidate_bound, candidate_max = roundoff_bounds(b, args.field) + error_bound = reference_bound + candidate_bound + absolute_bounds = [error_bound, error_bound / math.sqrt(len(x)), reference_max + candidate_max] + assert all(math.isfinite(value) for value in results[:3] + absolute_bounds + [norm, reference_bound]) + for name, actual, target, bound in zip(("L2", "RMSE", "Linf"), results[:3], expected[:3], absolute_bounds, strict=True): + assert target is not None and math.isfinite(target) + tolerance = max(1e-14, abs(target) * 2e-14) + assert abs(actual-target) <= bound+tolerance, f"{name}: {actual} != {target}, arithmetic bound {bound}" + actual, target = results[3], expected[3] + if args.field == "divergence" and norm <= reference_bound: + print("divergence relative-L2 oracle unavailable: reference norm interval reaches zero; absolute metrics and exact identities checked") + elif actual is None or target is None: + assert actual is target, "relative L2: unavailable mismatch" + else: + assert math.isfinite(target) + # Propagate the independently bounded absolute errors through E/N. + lower = max(l2-error_bound, 0.0) / (norm+reference_bound) + upper = (l2+error_bound) / (norm-reference_bound) + assert math.isfinite(lower) and math.isfinite(upper) + tolerance = max(1e-14, abs(target) * 2e-14) + assert lower-tolerance <= target <= upper+tolerance, f"relative L2: {target} outside arithmetic interval [{lower}, {upper}]" + print(f"independent SQLite/native-bit oracle passed: {args.field}, {len(x)} samples") + + +if __name__ == "__main__": + main()