diff --git a/docs/plans/fluid-accuracy-composition/execution-ledger.md b/docs/plans/fluid-accuracy-composition/execution-ledger.md index dced4e1f..121cc78e 100644 --- a/docs/plans/fluid-accuracy-composition/execution-ledger.md +++ b/docs/plans/fluid-accuracy-composition/execution-ledger.md @@ -17,3 +17,50 @@ Next implementation slice remains G1. Method-selection and experimental-validati - Opened draft PR120 from content commit 788d5e5163d7153b726b71a7168da9311bc60850. Final gate inspection found root source-inventory recursively rejects extra Lean files, so opt-in proofs moved beside the plan; root checker/inventory remain unchanged. Requalified final paths and recorded the correction rather than weakening the gate. - Final plan-local kernel run returned exit 0: both modules, 25-declaration audit and 3 negative probes passed; unchanged root exact-source/dependency gate passed. P0 proposal/proof artifact accepted for delivery in draft PR120; production stays Deferred, next G1 unstarted. + + +## 2026-10-20 — G1 admitted + +Explicit user implementation direction resolves prior G1 deferral. Frozen main remains bc2f472, PR115 included; protected audit119/plan120/pressurewarmstart59 untouched. New branch implement/g1-native-workflow-20261020. Source discovery required the bounded write-set correction recorded in plan.md before edits. Additive workflow controls/terminal profiles preserve current archives. Native interaction and performance remain prospective; no physical/GPU claim. + +Independent architecture review caught incomplete cancellation/fallback observation and incomplete request-control identity before qualification. Bounded re-plan in plan.md admits existing provider dispatcher/workspace observation hooks and an internal source accessor; no numerical-method change. Rejection, fallback initiation and cancellation remain distinct observed facts. + +### G1 core observation checkpoint + +Seven focused core contracts pass: actual candidate/fallback/appliedzero, short actualdt and exact baseline trajectory, before/after/in-stage cancellation and immutable state, canonical actual control/native state identity, illegal prefix/crossfield/limit rejection. Independent architecture review passes after reproducing and correcting missing-node, nativeRHS, cancellation and partial-diagnostic defects. Eight additive persistence contracts and sixty existing persistence integration tests pass independently; separate persistence review has no remaining findings. Native UI and fixed capture+cachedprojection budget remain pending; no G1 acceptance asserted. + +### G1 source implementation checkpoint + +Additive RCP4/RCT2 custody and native canvas/text/details/evidence integration are implemented. Independent architecture and persistence reviews pass; native UI source review has no remaining findings after six focused real-egui interaction/cache tests pass. Existing worker/runtime/producer-exit and old persistence contracts pass. Source moved Active → Implemented, then `verify` → Verifying. Actual native display workflow and fixed release capture+projection budget remain pending and must pass before acceptance. The core observation checkpoint b3db38d is pushed; the branch remains retained for the repository lead, with no merge/cleanup action. + +### G1 measured budget and retained native evaluation + +Source checkpoint bc494c1 is pushed in draft PR122 against plan120. Broad CPU run passed 597 tests in 74 suites with zero failures; current workflow contracts passed 15/15, existing persistence integration 60/60, strict all-target library/desktop Clippy and formatting passed. The first frozen release capture-plus-validated-projection evaluation passed: 30 paired intervals after five warmups, 0.4037% median added time against the unchanged 5% threshold, bitwise identical accepted native state after every pair. Independent reviewer recomputed raw hashes/math and approved the limited CPU workload claim; no optimization or criterion change was needed. + +The first actual normal-App run is retained as failed at `/tmp/rheon-native-display/failure-bc494c1`: live configured fallback was reached, but End/Left selection observation failed. Product versus isolated Xorg keyboard-map cause remains under investigation; stronger key-release/idle-frame event coverage passes. No native acceptance is asserted. G1 stays Verifying and PR122 stays draft. The 00:41 executor disconnect was transient: subsequent read/write/check calls succeeded; no worker or test was duplicated. Details and exact evidence hashes are in reports/g1-qualification.md. + +### G1 native verification finding — bounded repair + +In an explicit evdev XKB display environment, the unchanged bc494c1 binary passed actual pointer/keyboard/focus, exact live fallback interval and retained evidence selection, actual candidate/accepted residuals/limits/units, real cancellation (run 6, attempt 22, accepted generation 21, no publication), provider-absent cold reopening with identical run 4/ordinal 2 controls and archive hash, and owned App/provider cleanup. Independent native reviewer confirmed these observations. The first failed keyboard evaluation remains retained without a confirmed causal claim. + +The same review found the evidence window can exceed the 1000-pixel display because a multiline provider-binding Debug dump has no scroll area, hiding lower retained fields and Close. G1 returns Verifying → Active to repair only this bounded UI within its frozen write set. Add bounded scrolling/compact binding display and an overflow/close regression, then independently review and return Implemented → Verifying with a fresh source-bound native procedure. No numerical, archive, or acceptance-criterion change is admitted; PR122 stays draft. C1 readiness remains dependent on final G1 qualification. + +The repair now uses a viewport-relative bounded evidence body, compact hex provider identities and a footer Close outside the scroll area. A real configured receipt with bounded long binding values at 1000×600 verifies RHS initially offscreen, visible after real wheel scrolling and pointer-close with the workflow still open; key-release/idle-frame End/Left coverage also passes. All nine active workflow UI cases pass, two opt-in helpers remain ignored. Independent native reviewer approved the exact repair source. G1 returns Active → Implemented → Verifying; a fresh source-bound normal-App run and release budget evaluation remain required before acceptance. + +### G1 actual cold pointer/pan finding + +Checkpoint 3a86991 was pushed and built as the ordinary source-declared native binary. Fresh native interaction confirmed the bounded evidence repair, numerical details, live pointer/keyboard, real early cancellation (attempt 2 after accepted generation 1, no publication) and clean App exit. Partial screenshots and condition-wait diagnostics are retained separately; none constitutes full acceptance. The 00:56:43 disconnect callback did not lose source or processes: access checks found clean 3a86991 and the original App/driver active; work resumed without duplicate workers/runs. + +Actual cold interaction again showed canvas pan reaching Publication while details stayed Geometry. Independent pixel analysis identified the pan vector as exactly the pointer relocation from the previous control to the clicked node. A focused regression reproduced it: prior pointer at archive trigger (87,990), then pointer-move plus node press in one frame changed pan by (190.1,−635.5) before child hit testing. This is a product background/node gesture conflict, not resolved by keyboard-map changes or merely waiting for screenshots. G1 returns Verifying → Active for a bounded repair: retain actual press origin and initial pan, admit only background-origin gestures, and derive movement from the held pointer. Preserve node click/held movement without panning and actual background drag. Same source write set and unchanged acceptance criteria; no numerical or persisted-profile edits. Fresh independent review and source-bound native/performance evaluation follow. + +The gesture repair is implemented and independently approved: background-origin ownership, held displacement from actual press, exclusion of node origins, and disposal on release/reset/open/close or source invalidation. All eleven active workflow UI cases pass, zero fail; the two opt-in helpers remain ignored. Regressions include the exact 1600×1000 archive-trigger relocation, coalesced click/release/End with idle frames, node-held movement without pan, actual background displacement and invalidation during a held gesture. G1 returns Active → Implemented → Verifying. Final normal-App and fixed budget evaluations use the next source checkpoint, with native procedure starting only after both builds finish. + +### G1 accepted; bounded C1 preparation complete + +Implementation checkpoint `f6b7c8cd4628f6939c41567cecdf4a5231f60386` is pushed. Final fixed release evaluation passed 30 pairs after five warmups: 1.880860% median capture plus validated cache adoption against unchanged 5%, identical accepted native state after every pair. Independent architecture reviewer verified raw/binary/source hashes, workload, variability and order; PASS. + +After all builds completed, the same-source ordinary native binary passed the full normal-App live/evidence/numerical-details/cancel/close/provider-absent-cold/close-reopen workflow. Independent native reviewer verified 73 original artifact hashes, 22 accepted captures, exact unchanged run 4/ordinal 2 archive and controls, actual cancelled run 6/attempt 12 after accepted 11 with publication None, source/binary binding and complete owned App/provider/display absence. Drivers and live/cold App/display supervisors exited zero. No individual provider PID was sampled; only observed cleanup facts are claimed. Transient callbacks caused no observed work/process loss or duplicate runs. Failed prior evaluations remain retained alongside final evidence under `/workspace/rheon-g1-evidence`; actual archives and recorded executables are preserved there too. + +G1 transitions Verifying → Accepted, limited to G1-C/G1-U and the recorded performance scope. Claim register and visibility-gap disposition are synchronized; numerical F1–F8/physical/GPU/learned claims are unchanged. Source evidence and failure dispositions are in reports/g1-qualification.md, reports/g1-review.md and reports/g1-manifest.json. + +Only after G1 qualification, prepared reports/c1-readiness.md: C1-A Planned/dependency-ready with frozen two-module/declaration-only write set, existing pressure-v1/native-owner adapters, distinct family states, separately gated projection/whole-MAC candidates and explicit unavailable transfers. Independent architecture/contracts preparation review PASS; requested canonical G1-Accepted/C1-A-Planned status synchronization completed. C1 source implementation/acceptance is not claimed. P1/Q1 native method/reference work remains independent; repository lead coordinates shared declarations/status and all stacked-PR integration. No merge, protected-branch modification or branch cleanup performed. diff --git a/docs/plans/fluid-accuracy-composition/issues.md b/docs/plans/fluid-accuracy-composition/issues.md index 918876bd..d095ce41 100644 --- a/docs/plans/fluid-accuracy-composition/issues.md +++ b/docs/plans/fluid-accuracy-composition/issues.md @@ -12,7 +12,7 @@ The frozen audit remains authoritative for baseline observations; this table is | F6 coarse channel/interface diffusion | Medium | Q1 measured accuracy envelope retains old failure; Q2 transport selection. Open. | | F7 area-lumped obstacle pressure/load | Medium | Q2 common B/M/S and independent flux/traction/refinement; new weight alone insufficient. Open. | | F8 uncalibrated cohesion/contact work | Medium | Q3 capillary stress/drop/wave + impulse/work ledger; moving/coupled capability separate. Open. | -| Workflow visibility gap | New architecture requirement | G1 descriptor + actual receipts and native inspection; HTML only a provisional design aid. Open production requirement. | +| Workflow visibility gap | New architecture requirement | Closed for existing configured sealed MAC by [qualified G1](reports/g1-qualification.md): authoritative descriptor, real receipts, native live/cold interaction and independent review. Other families/editing remain outside that acceptance; HTML is still only a design aid. | | Hidden provider state/side effects | Contract risk | C1 explicit prepare/commit/abort or stateless/disposable candidate; old pressure seam cannot roll back external side effects. Open implementation gate. | | MARIN numeric observations | Specific missing input | V1 physical validation only blocked; no block to reference/graph/contract work. | diff --git a/docs/plans/fluid-accuracy-composition/plan.md b/docs/plans/fluid-accuracy-composition/plan.md index 4e2f273e..4f4e35f6 100644 --- a/docs/plans/fluid-accuracy-composition/plan.md +++ b/docs/plans/fluid-accuracy-composition/plan.md @@ -1,6 +1,7 @@ # Water accuracy and inspectable solver composition -Repair-plan status: **Deferred — production not admitted**. P0 is the separate plan/proof delivery completed in draft PR120; writing this proposal does not approve its candidate methods. Current phase: P0 delivered and qualified; production remains deferred. Exactly one next slice: **G1, inspect the existing configured MAC pressure workflow from authoritative composition and execution receipts**. A future authorized follow-up can select this plan unambiguously in conversation; the implementation owner then resolves the relevant deferral and approves the bounded direction as Planned, rechecks current main and freezes G1 contracts/write set before `start` transitions Planned to Active. The user need not recite a path or repeat already granted authority. This proposal alone does not start production. +Repair-plan status: **Active — production implementation requested by the user on 2026-10-20**. P0 remains the separate plan/proof delivery in PR120. **G1 is Accepted** at implementation checkpoint `f6b7c8c`, after independent native and fixed-budget qualification. Current phase: **C1 preparation complete / dependency-ready**. Exactly one next integration slice is **C1-A, the closed typed pressure-stage/native-family boundary and native baseline adapters**; its [frozen first-slice write set](reports/c1-readiness.md#frozen-first-slice-write-set) is Planned, not implemented. G1 followed Deferred → Planned → Active → Implemented → Verifying, returned to Active for actual native findings, then Implemented → Verifying → Accepted. Separate P1/Q1 native numerical work retains independent method/evidence gates and is not blocked by graph infrastructure. No repaired water-accuracy claim follows from G1. + ## Objective and acceptance @@ -8,7 +9,7 @@ Repair the eight findings in [the frozen audit](https://github.com/MrScripty/Rhe Objective acceptance requires (1) all F1–F8 dispositions backed by native CPU verification, with failed/unavailable capabilities still explicit; (2) independently reproduced convergence and conservation gates for each admitted family; (3) coherent fluid/solid/free-surface stress and mass accounting; (4) stage, compatible-subgraph and whole-family substitution demonstrated with adversarial classical fixture providers and exact provenance; (5) a working interactive 2D workflow inspector in the existing native application, from live execution through cold archive inspection; and (6) physical validation claimed only for observations actually retained with uncertainty. AI accuracy requires separately qualified model assets and comparisons; contract acceptance alone is insufficient. -Current acceptance: the original audit is complete and immutable; planning, research and new proof qualification are separate evidence. **None of the production repair, interactive native inspector, new solver accuracy or experimental-validation acceptance claims is satisfied by this proposal or its HTML prototype.** See [qualification](reports/qualification.md) for what was actually checked in this PR. +Current acceptance: the original audit remains complete and immutable; P0 planning/proof evidence remains separate. **G1-C/G1-U are satisfied for the existing configured sealed-MAC inspector** by actual CPU/archive/native interaction and fixed-budget evidence in [G1 qualification](reports/g1-qualification.md). New numerical accuracy, other solver families, learned deployment and experimental-validation acceptance remain pending their own gates. The HTML prototype establishes none of those production claims. [P0 qualification](reports/qualification.md) retains its original scope. ## Authority, constraints and reuse @@ -37,13 +38,13 @@ Owners below are **responsibility roles**, not assignments to an unverified pers ## Dependency milestones -All production milestones are **Deferred** implementation proposals awaiting explicit slice admission; none has acquired Planned status merely by being written. At `start`, admit only the selected direction and required bounded selection experiments, then use Planned/Active/Implemented/Verifying/Accepted as warranted. The particle algorithm decision and experimental inputs remain explicit gates. A method-selection experiment can be admitted without authorizing its winner as production. Each slice freezes its exact methods/coefficients/thresholds first; a failed gate returns to method selection rather than quietly relaxing tolerances. +G1 is **Accepted** and C1-A is **Planned / dependency-ready**. Other cells retain the source proposal's statuses until the repository lead integrates the separate numerical instance's admissions/results; they are not additional permission questions or graph prerequisites for P1/Q1. At `start`, admit only the bounded selected direction and required selection experiments. Particle algorithm and experimental inputs retain their explicit gates. Admitting a method-selection experiment does not authorize its winner as production. Freeze exact methods/coefficients/thresholds first; a failed gate returns to selection rather than quietly relaxing tolerances. | ID / lifecycle | Goal, dependencies and bounded write set | Acceptance and review | |---|---|---| | P0 / Accepted (proposal/proof artifact only) | This plan, research, isolated proof sources and compact manifests only. No `src` changes. | Qualified proof claims, independent math/architecture/standards reviews disposed, focused draft PR; no production claims. | -| G1 / Deferred | First vertical slice: existing configured sealed-MAC pressure execution → typed descriptor and attempt receipts → live/archive native workflow inspector. `simulation`, `liquid_step`, `solver_provider`, `session` observation seams; `recording` additive profile; desktop inspector/run-set/archive consumers and focused tests. Do not widen supported numerical domains. | [G1 exact acceptance](reports/verification.md#g1-native-workflow-inspection). Descriptor digest/actual interval/provider/fallback binding; corrupt/missing/legacy evidence handled; actual native pointer+keyboard+close/reopen/cancel workflow. Composition, persistence and GUI reviewers. | -| C1 / Deferred | After G1, add finite typed stage/subgraph/whole-family interfaces alongside pressure v1. Native-family facade delegates to existing state owners. Implement deterministic adversarial classical fixtures before learned deployment. `solver_provider`, family-specific step/session adapters, existing configured intent/custody/comparison; no arbitrary registry or tensor engine. | [Replacement gates](reports/verification.md#replacement-contract-gates): support/units/layout/operator identity; accepted trajectory unchanged for same native baseline; rejection/fallback/rollback under bad candidates, stale identity, errors and cancellation; cold inspection without runtime. Architecture + contracts review. | +| G1 / Accepted | Existing configured sealed-MAC execution → authoritative descriptor and real attempt receipts → exact live/archive/comparison native inspection. Additive RCP4/RCT2; numerical formulas/order/domains unchanged. | [Qualified result](reports/g1-qualification.md): native workflow and independent reviews pass; fixed 30-pair release capture/projection overhead 1.88086% ≤5%. | +| C1 / Planned (C1-A dependency-ready) | [Bounded readiness](reports/c1-readiness.md): closed typed pressure-v1/native-family adapters first; existing sealed projection subgraph and whole sealed-MAC candidates follow their executable gates. Facade delegates to native owners. Particle candidates/transfer remain explicitly unavailable until qualified. No registry, tensor engine or numerical-framework prerequisite. | [Replacement gates](reports/verification.md#replacement-contract-gates) unchanged. C1-R remains pending implementation/evidence; architecture/contracts review at each boundary, persistence/native review when custody/consumers change. | | P1 / Deferred | F1/F2/F5: planar wall comparison and linear-EOS equilibrium initialization/dt admission. Shared pair visibility and wall quadrature; pressure/density/viscosity derived together. `particle_fluid`, boundary geometry queries, focused reference fixtures; new formulation version, existing preview preserved. Native selection/reference tests are independent of G1; graph integration follows G1 support/evidence semantics. C1 is needed only for substitutable candidate integration. | [Particle gates](reports/verification.md#particle-wall-and-equilibrium-gates); select method only after native force/work + consistency studies. No density-only repair. Independent SPH and proof reviews. | | P2 / Deferred | F4: clipped-volume/centroid/mass quadrature, including variable-volume compatible particle subgraph after P1 selection. Geometry clipping/admission and initialization owners, no mesh import rewrite. | Rotation/phase sweep, analytic/independent clipped volume, uncertainty, disconnected/solid-centroid refusal; mass and native force quadrature gates. Geometry + particle review. | | Q1 / Deferred | F6: publish measured resolution/Courant guidance and add native verification matrix before changing transport. Existing channel/transport owners and source fixtures; retain coarse failure. If transport repair needed, select conservative geometric interface fluxes on common cut geometry in Q2. | Channel, time and interface shape/volume gates below. No claimed accuracy from volume alone. Numerics + verification review. | @@ -53,9 +54,11 @@ All production milestones are **Deferred** implementation proposals awaiting exp Suggested dependency order is G1 → C1, with P1 and Q1 native selection/reference tests independent of graph implementation, using existing audit evidence custody until graph integration; P2 follows selected P1 quadrature; Q2 follows Q1 geometry/transport decision; Q3 is admitted independently by family; V1 follows the capability it validates. No universal graph abstraction blocks numerical repairs. Every milestone can end in an explicit narrower supported envelope when its broader gate fails. -## Exact next-slice write set and admission +## Frozen G1 write set and next-slice admission + +G1 below is accepted and retained as its historical source boundary. The next slice is C1-A with the [frozen first-slice write set](reports/c1-readiness.md#frozen-first-slice-write-set); C1-B/C and transfers are separately gated and do not widen it implicitly. -G1 may modify `src/lib.rs` and `src/recording.rs` only for the new module declarations, plus `src/simulation.rs`, `src/liquid_step.rs`, `src/solver_provider/configured.rs`, `src/session/configured.rs`, `src/recording/provider.rs`, `src/recording/provider/runtime.rs`, `src/bin/desktop/run_sets/configured/archive.rs`, `src/bin/desktop/saved_comparison.rs`, `src/bin/desktop/configured_comparison_tests.rs` and `src/bin/rheon-desktop.rs`. Bounded new modules are `src/workflow.rs` (numerical descriptor/receipt types), `src/recording/workflow.rs` (new persisted profile/terminal-attempt ownership), and `src/bin/desktop/workflow_inspector.rs` with adjacent focused test modules; their parent module declarations and explicit affected lock/schema fixtures are included. Freeze actual module names and serialization profile at G1 admission after checking current main. No numerical formula, accepted ordering, public pressure v1 behavior or old persisted representation may change. Unexpected owners, generic registries, second custody stores, incompatible serialization, production solver repair or graph editing trigger replan before expansion. Additive docs/protocol and compact source-bound manifests under this plan are included; generated fields/logs stay outside Git. +G1 may modify `src/lib.rs` and `src/recording.rs` only for the new module declarations, plus `src/simulation.rs`, `src/liquid_step.rs`, `src/solver_provider/configured.rs`, `src/session/configured.rs`, `src/recording/provider.rs`, `src/recording/provider/runtime.rs`, `src/bin/desktop/run_sets/configured/archive.rs`, `src/bin/desktop/comparison.rs` (the `saved_comparison` module’s actual path), `src/bin/desktop/configured_comparison_tests.rs` and `src/bin/rheon-desktop.rs`. Bounded new modules are `src/workflow.rs` (numerical descriptor/receipt types), `src/recording/workflow.rs` (new persisted profile/terminal-attempt ownership), and `src/bin/desktop/workflow_inspector.rs` with adjacent focused test modules; their parent module declarations and explicit affected lock/schema fixtures are included. Freeze actual module names and serialization profile at G1 admission after checking current main. No numerical formula, accepted ordering, public pressure v1 behavior or old persisted representation may change. Unexpected owners, generic registries, second custody stores, incompatible serialization, production solver repair or graph editing trigger replan before expansion. Additive docs/protocol and compact source-bound manifests under this plan are included; generated fields/logs stay outside Git. ## Migration, compatibility and rollout @@ -78,3 +81,18 @@ Re-plan when a selected boundary method fails planar isolation/work, common geom The [qualified acceptance claim register](reports/verification.md#claim-register) separates P0 evidence from future production and physical validation. Supporting owners: [architecture/contracts](reports/architecture.md), [mathematical research](reports/mathematics.md), [verification and thresholds](reports/verification.md), [interactive provisional visual](reports/workflow-preview.html), [proof qualification](reports/qualification.md), [issues](issues.md), [execution ledger](execution-ledger.md). Durable ADRs should be extracted only when a selected architecture outlives this effort; these provisional method gates do not need premature ADR authority. + + +### G1 admission amendment — 2026-10-20 + +Implementation branch `implement/g1-native-workflow-20261020`, base plan `8422f77bb45e60b95c5694ed0b39e01377b62976`, current fetched main `bc2f472787060d9a69cb619ed2c2fea10fd754b6`. Implementation owner: delegated G1 Codex instance; integration and merge remain with repository lead/parent. Branch retained for review, no cleanup or merge authorized. + +Before source edits, discovery identified a bounded write-set mismatch: failed attempts are consumed in `src/bin/desktop/ai_comparison/configured_track.rs`, accepted recording and exact cached selection are owned by `src/bin/desktop/inspector.rs`, and `saved_comparison` is the module alias for `src/bin/desktop/comparison.rs`. Add these first two existing owners to G1 and correct the third path. Adjacent focused tests are included. This amendment preserves the existing Session/process/recorder/selection owners; introducing another custody store would violate admission. No numerical formula, accepted order, domain, or pressure v1 change is admitted. + +Frozen names: `src/workflow.rs`, `src/recording/workflow.rs`, `src/bin/desktop/workflow_inspector.rs`. Profiles: additive `RCP4:` controls for workflow-bearing accepted publications and `RCT2:` terminal status; retain SQLite v1, configured RSC1/profile and old RCP2/RCP3/RCT1 decode/encode meanings. Bounded fixed sealed-MAC descriptors, at most 256 node summaries and 1 MiB descriptor by criterion, additionally subject to existing 16 KiB recording metadata limit. In-process types are internal coordinated; persisted profiles are versioned. Actual owners emit receipts directly at numerical/provider/commit boundaries, never infer them from cancel callbacks. + +Parallel roles: root owns workflow core/numerical observation and plan/module declarations; native UI worker owns new desktop module, App integration and Inspector selection accessor; persistence worker owns new recording module, provider/archive/recording integration and configured-track observation. Shared files have disjoint sections and serial integration; no worker modifies plan/module declarations. Independent final architecture/persistence and native UI review required. G1-C/G1-U and fixed overhead gates remain unsatisfied until measured evidence. C1 preparation follows G1 qualification, not a prerequisite to numerical repairs. + +G1 observation re-plan before expansion: independent architecture review found that successful-return-only pressure observation loses host qualification/fallback facts on an in-solve cancellation, and that opaque input state alone does not identify forcing/source controls. Extend only `src/solver_provider.rs` and `src/provider_step.rs` for an internal optional dispatcher observer, retaining the public `solve` API/ordering byte semantics; extend `src/liquid_volume.rs` only with an internal source-stamp/rate observation accessor. These are existing numerical owners, not new schedulers, methods or domains. The direct numerical owner captures a bounded typed input summary/digest and a native RHS digest at actual assembly. The full finite ordered receipt trace, typed dependencies and publication association are validated at storage ingress. Independent architecture review owns these corrections; fixed G1 acceptance is unchanged. + +The directly affected standalone consumer `tests/saved_comparison_ui_contract.rs` is included for its new legacy-workflow-unavailable regression. It imports `comparison.rs` independently of App; exercising the new exact-side API there also preserves strict all-target dead-code checks without weakening production lints. diff --git a/docs/plans/fluid-accuracy-composition/reports/c1-readiness.md b/docs/plans/fluid-accuracy-composition/reports/c1-readiness.md new file mode 100644 index 00000000..350b3dfd --- /dev/null +++ b/docs/plans/fluid-accuracy-composition/reports/c1-readiness.md @@ -0,0 +1,40 @@ +# C1 bounded composition readiness + +Status: **Prepared / dependency-ready after independently qualified G1** at `f6b7c8c`. C1 is Planned, not implemented or accepted. The user has supplied implementation direction; no learned assets, universal graph engine or further product-direction question is prerequisite to the first slice. Repository lead coordinates shared declarations, plan status and integration with the separate P1/Q1 instance. + +## Selected boundary and sequence + +Reuse PR115/D1 immutable configured intent, runtime pins, Session publication, supervised process owner, recording custody and exact comparison selection. Preserve pressure v1 and RCP2/RCP3/RCP4/RCT1/RCT2 meanings. A facade delegates to existing numerical state owners; it neither schedules a second simulation nor stores a second accepted history. + +| Slice | Concrete direction | Admission boundary | +| --- | --- | --- | +| C1-A — next | Closed typed stage/native-family boundary; adapt pressure v1 and delegate native baseline calls to existing owners. Distinct MAC/particle requests and snapshots retain their own fields and precision. | No new method, provider support outside pressure v1, persisted profile, interpreter or implicit conversion. Subgraph/whole-candidate variants stay explicitly unavailable until their gates execute. | +| C1-B | Existing sealed-MAC pressure/projection subgraph: tentative cell pressure/staggered velocity, actual RHS/dt, geometry/operator/gauge and projection witnesses. | Host recomputes actual equations/constraints. Shape compatibility is insufficient. This is existing projection, not newly qualified traction-coupled Stokes. Freeze residual/roundoff criteria before evaluation. | +| C1-C | Whole existing sealed-MAC interval candidate, tentative native carrier/tracer/pressure/fraction state and history. Classical fixtures precede learned assets. | Session remains sole publisher. Full native-state, actual interval, mass/source, momentum/operator and history gates precede advertised support. Invariants do not confer accuracy. | +| Separate transfer admission | Versioned MAC→particle or particle→MAC operators with explicit support/reconstruction/budgets. | Initially unavailable. No facade/archive/comparison silently converts families. Implementing transfer requires its own frozen protocol and review. | + +Particle variants delegate only the existing native owner/preview capability; type presence does not qualify substitution or select wall/EOS/kernel methods. P1 owns that selection and transaction-ready adapter. Stokes, geometry, transport, capillarity and physical validation retain P/Q/V gates. Native numerical studies execute directly and are not blocked by C1-A/B/C. + +## Frozen first-slice write set + +C1-A may add `src/solver_provider/composition.rs`, adjacent focused tests, `src/native_family.rs` and adjacent focused tests. `src/solver_provider.rs` and `src/lib.rs` may change only for declarations/re-exports. Plan/protocol/review docs under this plan are included. Use existing public pressure, Session and particle-owner interfaces. Do not modify particle formulas, private numerical storage, recording schemas, process supervision or desktop flows in C1-A. If discovery requires another owner, record the concrete replan before editing and coordinate shared files with the lead. + +C1-B/C are sequenced work, not unbounded source admission. Their protocols must freeze exact native request/candidate/transaction contracts and additive intent/custody versions before edits/evaluation. Expected owners are existing `provider_step`, `simulation`/`liquid_step`, `session`, configured provider/runtime, recording and exact selection consumers. No registry or tensor engine is admitted. Adapt pressure v1; never reinterpret it as subgraph/whole-family support. + +## Contract facts and lifecycle + +Bind family/formulation/version, native association/layout/precision, exact accepted generation/input digest, actual shortened interval, geometry/operator/component/gauge, SI material/force/flux units, supported boundaries and explicit transfer identity. Unknown/incompatible inputs are typed refusals before invocation. Declared metadata and observed runtime facts stay distinct. + +Candidates are tentative native state. All fallible allocation, external work and qualification precede publication. Stateful candidates need tested prepare/abort or snapshot semantics for history/RNG, disposable prepared state and infallible local adoption. Prepare/abort failure or uncertain external commit terminates/invalidates the instance; retry requires accepted-history reconstruction and demonstrated replay/identity equivalence. Fallback uses identical immutable input/dt/operators. Existing caller-owned pressure v1 gains no promise to roll back arbitrary hidden provider state. + +Retain failed/cancelled attempts separately from accepted rows and preserve accepted-but-unrecorded tails. New persisted variants must be additive and cold-inspectable without runtime assets; G1 profiles retain exact meanings. No I/O/model execution enters cached paint. + +## Fixed qualification obligations + +Use existing [replacement gates](verification.md#replacement-contract-gates), not looser thresholds. Before evaluation, commit the C1 protocol, fixture constants, coefficients, tolerances, resource bounds and source/runtime identities. For the initial sealed reference, reuse G1 fixed grid/material/force/dt/pressure controls; compare adapter/facade with the same direct native path over 100 accepted intervals. Freeze bitwise comparison when invoking the same algorithm/order. This is contract equivalence, not water validation. + +At every admitted stage/subgraph/family level test correct candidates; nonfinite/range/shape/unit/version/operator mismatch; unsupported geometry; wrong shortened dt/generation; plausible wrong fields including divergence-free wrong dynamics; refusal/error/budget/runtime failure; cancellation around candidate/commit; immutable accepted state/history; same-input fallback; prepare/abort/uncertain-commit failure and invalidation. Recompute required intermediate/full residual witnesses. Independently qualify trajectory accuracy before claiming it. + +Later transfer admission needs positive mass, closed-domain binary64 relative mass/momentum defect ≤1e-12, translated/rotated constant/linear reconstruction tests, separately measured energy/work and target reconstruction order ≥0.9. No exact-energy/reversibility or index-based physical particle identity is implied. + +Architecture/contracts review qualifies C1-A; persistence/native UI reviewers join custody/consumer work; independent numerics review owns method-specific gates. Publish focused checkpoints/PRs and coordinate merges with the lead. C1-A is ready to start within this bound; C1-R acceptance remains pending implementation/evidence. diff --git a/docs/plans/fluid-accuracy-composition/reports/g1-contract.md b/docs/plans/fluid-accuracy-composition/reports/g1-contract.md new file mode 100644 index 00000000..581b2665 --- /dev/null +++ b/docs/plans/fluid-accuracy-composition/reports/g1-contract.md @@ -0,0 +1,13 @@ +# G1 workflow custody and native inspection contract + +The numerical composition owner emits a finite sealed, constant-density MAC descriptor. It describes the existing stage order, operator, SI units, layouts, geometry, pressure settings, viscosity disabled versus applied zero, data dependencies, pressure iteration loop, fallback and accepted time loop. It is not an executable graph. Family/state conversion and graph editing remain unavailable. + +Each actual attempt binds run/attempt/accepted-input/attempted-generation identity, exact native input state and actual request-control digests, requested/actual interval, descriptor digest, observed provider support/prediction/qualification/fallback facts, actual combined residual threshold, pressure-call timing scope, corrected divergence, legal completed-node prefix and optional publication. Failed/cancelled attempts cannot claim publication. Candidate/accepted metrics require their actual numerical boundary. Parameters and declared recipe provenance remain distinct from observed execution and physical accuracy. + +`src/recording/workflow.rs` owns additive `RCP4:` accepted controls and `RCT2:` terminal details inside the existing SQLite v1 envelope. They wrap the original bounded RCP2/RCP3 or RCT1 binary payload, without double-hex expansion, plus source binding and validated workflow facts. Existing RSC1/configured profile, old encoders/readers, field custody, resource limits and uncertain-write rules retain their meanings. A recording failure after physics commit is an explicit accepted tail, never rollback. Descriptor/summary limits and the existing 16 KiB metadata cap apply before publication. + +Readers validate exact source/descriptor/interval/provider/publication association, legal node/status prefixes, increasing retained attempt IDs, immediate prior native-state digest where available and runtime-outcome consistency. Configured archive inspection also compares actual control digests against reconstructed frozen intent. Missing old workflow evidence is unavailable; corrupt/cross-linked/unknown versions are typed errors. Terminal inspection works at generation zero without an accepted row or model process. + +The existing Inspector owns exact selected disk records and caches the validated attempt during selection (at most current and immediately previous rows). The workflow view consumes the immutable cached projection. Explicit terminal loading/auditing performs archive I/O; canvas paint does not open files or start models. Pointer, keyboard, focus, text equivalent, readable zoom, pan/reset, evidence, cancel/close/reopen and stale-source behavior are qualified separately by the native procedure in [g1-protocol.md](g1-protocol.md). Old material/carrier/generic-field inspection remains available. + +Contract classes: coordinated in-process Rust types; versioned persisted profiles. No external pressure-v1 protocol, numerical formula, stage order or supported physical domain changes. Architecture remains Session/numerical owners → recording owner → cached native view; no second recorder, process supervisor, registry or solver state is introduced. diff --git a/docs/plans/fluid-accuracy-composition/reports/g1-manifest.json b/docs/plans/fluid-accuracy-composition/reports/g1-manifest.json new file mode 100644 index 00000000..62a09901 --- /dev/null +++ b/docs/plans/fluid-accuracy-composition/reports/g1-manifest.json @@ -0,0 +1,193 @@ +{ + "version": 1, + "scope": "G1 existing configured sealed-MAC workflow inspection and custody", + "claims": { + "G1-C": "accepted for recorded CPU/archive contracts", + "G1-U": "accepted for recorded native software-display workflow", + "performance": "accepted for fixed capture plus validated cached projection workload", + "C1-R": "not implemented; C1-A prepared/dependency-ready", + "physical_or_GPU_or_learned_accuracy": "not claimed", + "release_or_hermetic_artifact": "not claimed" + }, + "implementation": { + "commit": "f6b7c8cd4628f6939c41567cecdf4a5231f60386", + "tree": "50a4abfae98b26c0b28474dcd19609252bb046ae", + "source_inventory": { + "path": "/workspace/rheon-g1-evidence/source-material-f6b7c8c.json", + "bytes": 21979, + "sha256": "6347d65cb3508eba8a0184e6ac2546141d148d80bee35badaa9fc9e663646e85" + }, + "source_revision_environment": "f6b7c8cd4628f6939c41567cecdf4a5231f60386", + "build_material_environment": null, + "subsequent_acceptance_changes": "documentation only" + }, + "binaries": { + "native": { + "path": "/workspace/rheon-g1-evidence/bin/rheon-desktop-f6b7c8c", + "bytes": 272686560, + "sha256": "0e0d284145ced8cbaeb141be58ee2a1aa30034c8db0f54a37a672968608f90e2" + }, + "release_test": { + "path": "/workspace/rheon-g1-evidence/bin/rheon-desktop-release-tests-f6b7c8c", + "bytes": 24153104, + "sha256": "542b872ebbbe9ebfd73897920ce5b52c0ff1fe35a891cd43fdfdad02542cdee6" + }, + "native_fixture_generator_sha256": "4b540640d93dd0f41dfdff5de9f473b95b3fe0dce07a1d1a356f6cd6d5c8090d" + }, + "native": { + "environment": "isolated dummy Xorg :91;1600x1000x24;software GLX/llvmpipe;evdev/us;XTEST", + "observations": { + "path": "/workspace/rheon-g1-evidence/native/final-f6b7c8c-evdev/final-acceptance-run.json", + "bytes": 12854, + "sha256": "6f19eabd8b1177e571a7de9a3f78eccc0bb8a07e7c4169350790905fc5ba3d99" + }, + "retained_artifact_index": { + "path": "/workspace/rheon-g1-evidence/native/final-f6b7c8c-evdev-index.json", + "bytes": 10562, + "sha256": "8d712f79a175def1b45d81f46b826d055b5cf85e0def01b6b76608d3e9795d7e" + }, + "actual_archives_index": { + "path": "/workspace/rheon-g1-evidence/native/rheon-native-final-g-recordings-index.json", + "bytes": 2885, + "sha256": "a3da6fac32d1c837c0ca5a92f4a97df35ff9f571f384dd1890c99348534caefe" + }, + "fixture_index": { + "path": "/workspace/rheon-g1-evidence/native/rheon-native-workflow-20261020g-index.json", + "bytes": 2206, + "sha256": "e8d669146453683cfbaa7daab866bf06295d6734c91ded289fdfedfdd5786cb9" + }, + "selected_run_ordinal": [ + 4, + 2 + ], + "archive_sha256": "99835798215557b8cbe85399b075fb9789124a6d887015ecfd1d55e12a970a70", + "selected_controls_sha256": "9716f406a0170248c61c0185f2ec3a5c05ca67cf3b087c4c79cc6b382c469a70", + "live_cold_identical": true, + "cancelled_run_attempt_last_accepted": [ + 6, + 12, + 11 + ], + "cancelled_publication": null, + "driver_App_display_exit_codes": 0, + "cleanup": "independently checked; no remaining Python/native App/owned display; no individual provider PID was sampled", + "archive_path_mapping": { + "/tmp/rheon-native-final-g-session/.generated/recordings": "/workspace/rheon-g1-evidence/native/rheon-native-final-g-recordings" + } + }, + "performance": { + "protocol": "docs/plans/fluid-accuracy-composition/reports/g1-protocol.md", + "n_warmup": 5, + "n_pairs": 30, + "limit_median_fraction": 0.05, + "measured_median_fraction": 0.018808600471844183, + "raw_log": { + "path": "/workspace/rheon-g1-evidence/performance-f6b7c8c.log", + "bytes": 1080, + "sha256": "ddef3f204e78bb43b104e71e059d0b55c04b52e8c5f8edbab579be3689ff8a16" + }, + "summary": { + "path": "/workspace/rheon-g1-evidence/performance-f6b7c8c-summary.json", + "bytes": 800, + "sha256": "34e16ad1f0fd2e45662659bf265825ba8abdac8407beed24927d7e2a6af67104" + }, + "build_log": { + "path": "/workspace/rheon-g1-evidence/release-build-f6b7c8c.log", + "bytes": 234, + "sha256": "3d34e93e39e5a91a52420ea5905f7dc19da80360b7bb5874e60ff37d83fc137c" + }, + "environment": { + "path": "/workspace/rheon-g1-evidence/environment.json", + "bytes": 1120, + "sha256": "09b3b48d55862d14b255a6899df7ec3bb1c5e6bb698d59bd34062942060ce3cc" + }, + "scope": "actual numerical capture plus validated set_attempt cache adoption; excludes disk, layout, paint, rasterization, models and other configurations", + "independent_disposition": "pass; variability/order do not obscure margin" + }, + "verification": { + "broad_CPU": { + "passed": 597, + "failed": 0, + "ignored": 4, + "suites": 74, + "log": { + "path": "/workspace/rheon-g1-evidence/core-tests.log", + "bytes": 69395, + "sha256": "d9be2849912216ea1dcdb9ad525ba5fa3cbabe809db347625a7ff5384208353f" + } + }, + "workflow_core_persistence": { + "passed": 15, + "failed": 0, + "ignored": 0, + "log": { + "path": "/workspace/rheon-g1-evidence/workflow-contracts-final.log", + "bytes": 3631, + "sha256": "6eb37ef087590a3d70ad84a048f1bd40f6cf64f38646e919e43cfbdbe65cedda" + } + }, + "focused_UI": { + "passed": 11, + "failed": 0, + "ignored_opt_in": 2, + "log": { + "path": "/workspace/rheon-g1-evidence/native/final-3a86991-evdev/pointer-pan-fixed-disposal.log", + "bytes": 1959, + "sha256": "5f6f20c4cc132a434c7cf69ff7c4061e9ae9ef6d7eacb51fa4efe8836a19b8ed" + }, + "tested_UI_sha256": "4176efb859e9ed2e0041a6ca7b9f87c5590ed2aa7541a83fc225ce6e795ddd6f", + "tested_tests_sha256": "577bc23f06a8cd794b16d42cd347a024d627443f3d1cf5d4a6230b21c76a24e2", + "binding": "tested source bytes match f6b7c8c; log retained before checkpoint" + }, + "legacy_comparison": { + "path": "/workspace/rheon-g1-evidence/legacy-comparison-workflow.log", + "bytes": 451, + "sha256": "f25246ba15cc4de835d174a3b113d673183dbfef3b1c7570865e39d5f009a1d7" + }, + "strict_desktop_Clippy": { + "path": "/workspace/rheon-g1-evidence/desktop-all-targets-clippy-gesture-final.log", + "bytes": 117, + "sha256": "c70b4f8aca042a0d153d1a3a63b1ca9bf67e9d0babf178e13ad5b5b2e322f6b7" + }, + "strict_core_Clippy": { + "path": "/workspace/rheon-g1-evidence/core-all-targets-clippy.log", + "bytes": 118, + "sha256": "ef6ea4a167290ff7512146617e999d535b22dddd3f14dd75d625c7eee5db33c7" + }, + "format": { + "path": "/workspace/rheon-g1-evidence/format-gesture-final.log", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + "retained_prior_evaluations": [ + { + "path": "/workspace/rheon-g1-evidence/native/failure-bc494c1-index.json", + "bytes": 2703, + "sha256": "e2003623690b7513c495efbe86caea254c319feced49106feae020e4cd112235" + }, + { + "path": "/workspace/rheon-g1-evidence/native/final-bc494-evdev-index.json", + "bytes": 8742, + "sha256": "d6b86598dc5d602fdf5652b1f18046726e74e2c44384183be5788ea27b7a95b5" + }, + { + "path": "/workspace/rheon-g1-evidence/native/final-3a86991-evdev-index.json", + "bytes": 24290, + "sha256": "50ccd5fe6cde88a404393791cb9e04f595fe0170c6d1ed5b983f23b07d9f7c88" + }, + { + "path": "/workspace/rheon-g1-evidence/performance-bc494-first.log", + "bytes": 1080, + "sha256": "31ea8d6f959e1334dca744335adcdeb910b1a49a512b03c49a756dfd2d8e6633" + } + ], + "independent_reviews": { + "architecture_core": "pass; no numerical formula/order/domain widening", + "persistence": "pass; additive profiles and historical meaning", + "native_source_and_workflow": "pass at final implementation", + "performance": "pass at final implementation", + "C1_readiness": "pass; canonical status synchronized" + }, + "integration": "PR122 stacked on PR120; repository lead owns retarget/merge; no merge or branch cleanup" +} diff --git a/docs/plans/fluid-accuracy-composition/reports/g1-protocol.md b/docs/plans/fluid-accuracy-composition/reports/g1-protocol.md new file mode 100644 index 00000000..6070e1b9 --- /dev/null +++ b/docs/plans/fluid-accuracy-composition/reports/g1-protocol.md @@ -0,0 +1,42 @@ +# G1 implementation qualification protocol + +Authority: `plan.md` G1 admission and the fixed acceptance in `verification.md`. This protocol was frozen before native workflow and overhead evaluation. No numerical formula, stage order, supported domain, pressure-v1 meaning, audit criterion or old archive meaning may change. Retain failed evaluations. + +Source base: plan120 `8422f77` on main `bc2f472`. Use Rust 1.92.0, existing binary64 pressure/fractions and f32 carrier. The material preset does not confer physical validation. Native fixtures use the existing Session, Worker, process supervisor, immutable configured intent and deterministic classical candidates. + +## Contract checks + +Run focused workflow and additive SQLite tests, then affected existing pressure, Session, recording/runtime and desktop worker/producer/archive/selection tests. Cover viscosity off, applied zero and nonzero; shortened actual interval; accepted, rejected, support-refused and failed providers; native fallback; cancellation before provider, after return, after qualification, during native fallback and before commit. Include accepted generation zero and attempted generation one. + +Assert immutable accepted state and no accepted row for failed intervals. Preserve explicit accepted but unrecorded tails. Mutate source, interval, native input, request controls, descriptor digest, ordered nodes, runtime outcome and publication association. Unknown versions/kinds, oversized inputs and corrupt controls must reject. Known missing or legacy workflow evidence remains unavailable. + +## Native workflow + +Use the normal native App with isolated temporary project, settings and output roots. Prepare saved intent and actual fixture-provider archives through the existing Worker in the test-only fixture preparation function. Use an isolated Xorg dummy display with software graphics and XTEST input, without an ambient user session. + +Through normal controls: + +1. Open a saved configured run set and rerun from zero. +2. Open the live workflow. Select nodes by pointer and keyboard with visible focus. +3. Inspect actual interval, provider/fallback, candidate/accepted residuals, limits and units; change the selected accepted interval and open retained evidence. +4. Cancel and close. Remove the provider executable, then cold reopen the saved run and inspect the same retained descriptor/receipts. +5. Exit and reap owned application/provider processes. + +Focused real-egui tests also cover generation-zero terminal inspection, invalid/missing/legacy states, source changes, pan/zoom/reset, text navigation, focus and close/reopen. Startup and screenshots alone cannot pass the workflow gate. Keep screenshots, interaction/assertion logs and archives outside Git; commit only compact source/binary hash manifests. + +## Fixed capture plus cached projection budget + +Workload: sealed 16³ grid over 1 m³, density 998.2 kg/m³, represented fraction 0.5, force acceleration [0.001, 0, 0] m/s², requested dt 0.001 s, Jacobi PCG v1, no viscosity/provider. Pressure limits: relative 1e-10, absolute 1e-12 m³/s², predicted divergence 1e-8 s⁻¹, actual divergence 1e-5 s⁻¹, maximum 2,000 iterations. + +Baseline: existing `Session::step_with_pressure_provider(None)`. Candidate: `step_with_configured_pressure_observed(None)` plus the actual validated cached `WorkflowInspector::set_attempt` projection. Disk I/O and rasterization are outside this metric; bounded selection and cached-paint tests cover them separately. + +Use optimized release, the same CPU/process, five warmup intervals, then exactly thirty paired intervals with alternating first path. Record each pair in nanoseconds. Compute the median of the paired `(candidate / baseline) - 1` values. Outside the timers, assert identical accepted state, pressure, fractions and time after every pair. The fixed target is at most 5% median added capture-plus-projection time. Report environment, variability and raw pairs. If variability dominates, classify the result unresolved. Do not change physics, workload or threshold to obtain a pass. + +Reproduction: + +``` +cargo test --release --locked --features desktop --bin rheon-desktop \ + paired_capture_and_cached_projection_overhead_protocol -- --ignored --nocapture +``` + +The descriptor must remain at most 1 MiB and node summaries at most 256 per interval; the existing 16 KiB persisted metadata limit also applies. Formatting and strict Clippy cover library/desktop features. Independent architecture, persistence and native UI reviews must dispose material findings. C1 remains dependency-gated until G1 qualification. diff --git a/docs/plans/fluid-accuracy-composition/reports/g1-qualification.md b/docs/plans/fluid-accuracy-composition/reports/g1-qualification.md new file mode 100644 index 00000000..efc3d708 --- /dev/null +++ b/docs/plans/fluid-accuracy-composition/reports/g1-qualification.md @@ -0,0 +1,48 @@ +# G1 qualified native workflow inspection + +**G1-C and G1-U pass for implementation checkpoint `f6b7c8cd4628f6939c41567cecdf4a5231f60386`**, tree `50a4abfae98b26c0b28474dcd19609252bb046ae`. Independent architecture, persistence, native UI and final performance reviews disposed material findings. The fixed [protocol](g1-protocol.md) and [contract](g1-contract.md) remain unchanged. This accepts inspection/custody for existing configured sealed-MAC execution, not new numerical accuracy, physical validation, learned-model qualification, hardware GPU support, browser parity, universal accessibility or packaged-release qualification. + +## Contract and regression evidence + +| Evidence | Result and scope | +| --- | --- | +| Native CPU library/integration | 74 suites: 597 passed, zero failed, four explicitly ignored. Numerical/persistence production source is unchanged by subsequent UI repairs. | +| Current core/workflow persistence | 15 passed, zero failed or ignored: exact control/native-state/RHS binding, legal prefixes, actual interval/provider/fallback, cancellation, corrupt evidence and additive custody. | +| Existing persistence integration | 60 passed independently, including historical profile meaning; real producer-exit/model-absent reader passed. Existing integration tests are also in the broad CPU run. | +| Final native view contracts | 11 passed, zero failed, two opt-in helpers ignored in the ordinary suite. Live/archive/comparison selection, missing/invalid/legacy states, generation-zero terminal, cached source-free paint, pointer/keyboard/focus/loops, overflow, close/reopen and gesture ownership. | +| Compatibility consumer | Legacy exact-side saved-comparison workflow-unavailable regression passed; material/carrier archive selection/export remained available. | +| Static checks | Formatting and strict all-target library/desktop Clippy passed. Final reviewed UI/test hashes match the implementation checkpoint. | + +Raw evidence and recorded binaries remain outside Git under `/workspace/rheon-g1-evidence`. The [compact manifest](g1-manifest.json) binds source inventories, executable hashes and retained indexes. Builds set `RHEON_SOURCE_REVISION` to the tested checkpoint and leave `RHEON_BUILD_MATERIAL_SHA256` unset; no hermetic-build claim is made. Subsequent acceptance/handoff edits are documentation only. + +The original P0 manifest remains the historical PR120 snapshot; its mutable plan/ledger hashes are not a claim about this implementation branch. G1 uses its separate manifest and does not refresh or broaden the original proof/audit qualification. + +## Actual native workflow + +The normal desktop App ran on isolated dummy Xorg `:91`, 1600×1000×24, software GLX/llvmpipe, explicit evdev/us XKB and XTEST input. Project/settings/data/cache roots were isolated. The existing Worker prepared immutable configured intent; deterministic classical provider execution produced real accepted and quality-gate/native-fallback intervals. No learned assets were used. + +The final procedure passed saved-set rerun from zero, live accepted interval selection, pointer and End/arrow selection with visible focus, exact fallback selection, reachable retained evidence/footer Close, candidate/accepted residuals and actual limits/units, actual divergence, real cancellation, close, provider-executable removal, cold reopening of the same retained graph, keyboard/pointer inspection and close/reopen. Cold stable fallback focus is established by capture 22, rather than transient focus paint in capture 17. Initial saved-set capture was a pending rendered frame: the bounded condition wait succeeded next frame. That initial frame remains a nonaccepting observation. + +Exact publication: run 4, ordinal/attempt/publication 2, accepted input generation 1, interval start 0.0625 s and requested/actual dt 0.0625 s. Live/cold archive SHA-256 `99835798215557b8cbe85399b075fb9789124a6d887015ecfd1d55e12a970a70` and controls SHA-256 `9716f406a0170248c61c0185f2ec3a5c05ca67cf3b087c4c79cc6b382c469a70` are identical. Descriptor, native input, actual request, RHS and provider identities were displayed and independently checked. Provider removal did not change the archive. + +Actual cancellation: run 6, attempted generation 12 after accepted generation 11, `Cancelled`, publication `None`; finalized archive retains 12 rows including construction and accepted generation 11. Live App PID 56713, cold App PID 56956 and display PID 1574 exited with supervisor code zero. Independent checks found them absent and no Python/provider/native App/display processes remaining. No individual provider PID was sampled; no such PID/reap claim is invented. + +Independent native review checked the 73-entry original artifact index, 22 accepted capture hashes, source/executable identity, exact live/cold SQLite bytes and cleanup. Final observation manifest SHA-256: `6f19eabd8b1177e571a7de9a3f78eccc0bb8a07e7c4169350790905fc5ba3d99`. Durable copies include actual live/cancel archives and immutable intents, not only seed fixtures. + +## Fixed release capture-plus-projection budget + +Final source passed the unchanged **≤5% median** criterion: **1.880860%** added time across exactly 30 pairs after five warmups. Every pair asserted bitwise identical accepted native fields, stamps, pressure, fractions and times outside timers; first-path order alternated. Candidate includes actual validated `WorkflowInspector::set_attempt` cache adoption. Disk I/O, graph paint/layout/rasterization, models and other configurations are outside this metric. + +Median baseline was 17,794,716.5 ns and candidate 18,163,490 ns. The criterion uses paired ratios, not a ratio of these medians. Paired quartiles: −2.3406%, 1.8809%, 4.4662%; extrema −9.0839%, 8.1553%. Sorted tenth–twenty-first ratio bracket: −0.1806% to 3.3220%; alternating-order medians: 1.7925%, 2.3075%. Independent review confirmed variability/order do not obscure the margin. CPU: Xeon Platinum 8573C, four-CPU cgroup, 16 GiB limit, Linux x86_64, Rust 1.92.0 optimized release. Owned builds/Apps/providers were stopped during timing; only the idle display remained. + +Release test binary SHA-256: `542b872ebbbe9ebfd73897920ce5b52c0ff1fe35a891cd43fdfdad02542cdee6`; final timing log SHA-256: `ddef3f204e78bb43b104e71e059d0b55c04b52e8c5f8edbab579be3689ff8a16`. Earlier bc494c1 evaluation passed at 0.4037% and is retained, not substituted for final-source evidence. No threshold, workload or numerical formula changed. + +## Retained failures and repairs + +Actual display review found two source defects missed by initial event tests. Long provider bindings overflowed evidence: the repair bounds its scrolling body, displays compact hashes and keeps Close reachable. The first height-only cap failed a 1000×600 footer check; the corrected cap uses remaining viewport space. + +Cold node clicks consumed pre-press relocation as parent-canvas drag, shifting the node before hit testing. Independent pixel analysis and a failing regression reproduced pan `(190.1, −635.5)` from one relocation-plus-press. The repair owns background-origin gestures and measures held displacement; node-origin gestures cannot pan, and invalidation/release/open/reset/close dispose ownership. Coalesced click/release/End with idle frames and actual background drag pass. Keyboard-map changes alone did not repair it. + +Failed source evaluations, late Cancel clicks on completed runs and driver frame/wheel diagnostics remain indexed outside Git. No cancellation was claimed from completion, no startup/screenshots replaced interaction, and no process launch implied acceptance. Transient executor callbacks were checked against actual source/process state; evidence persisted without duplicate workers or recovery-triggered runs. + +G1 is Accepted for this scope. [C1 readiness](c1-readiness.md) prepares the next bounded work; P1/Q1 numerical selection/reference work remains independent. Repository integration, CI disposition and retargeting/merge of stacked PR122 remain with the repository lead. diff --git a/docs/plans/fluid-accuracy-composition/reports/g1-review.md b/docs/plans/fluid-accuracy-composition/reports/g1-review.md new file mode 100644 index 00000000..38564224 --- /dev/null +++ b/docs/plans/fluid-accuracy-composition/reports/g1-review.md @@ -0,0 +1,14 @@ +# G1 independent review disposition + +Separate Codex instances reviewed architecture, persistence and native UI without editing the source under review. **All final bounded dispositions pass** at implementation `f6b7c8c`. Independent review of the subsequent C1 preparation also passes; canonical status was synchronized as requested. + +| Review | Material findings disposed | Final evidence | +| --- | --- | --- | +| Architecture/core | Exact actual controls/native state/RHS; accurate typed dependencies; legal executed prefixes; no premature/over-limit diagnostics or invented publication; true pressure/viscosity cancellation and partial fallback facts. | Seven core and eight persistence contracts pass; independent adversarial harness reproduced/corrected defects. Numerical formulas/order/domains unchanged. | +| Persistence | Additive RCP4/RCT2, old profile meanings; source/provider/interval/runtime binding; prior state and increasing attempt IDs; generation-zero terminal; accepted-but-unrecorded tails; historical Inspector regression. | Eight new contracts, 60 existing integration cases and actual producer-exit/model-absent reader pass. Numerical/persistence source unchanged by final UI repairs. | +| Native UI source | Stable node IDs, replacement focus/view, visible loops, units/candidate-vs-accepted diagnostics, exact comparison selection/stale invalidation, comparison terminal isolation; actual evidence overflow and pre-press click-pan defects. | Eleven focused cases pass. UI/test SHA-256 `4176efb859e9ed2e0041a6ca7b9f87c5590ed2aa7541a83fc225ce6e795ddd6f` / `577bc23f06a8cd794b16d42cd347a024d627443f3d1cf5d4a6230b21c76a24e2`. | +| Actual native workflow | Live/cold pointer and keyboard focus, reachable evidence/footer, values/limits/units, actual cancellation, exact source/receipt identity, provider absent, close/reopen and process cleanup. | Final manifest, all 73 original indexed artifacts and 22 accepted capture hashes independently verified. Same live/cold run 4/ordinal 2 bytes. Final stable cold fallback focus verified in capture 22. No remaining blocker. | +| Fixed final-source budget | Frozen workload/timer scope, 30 alternating pairs, identical state outside timers, raw/binary/source hashes, variability and order. | Independently recomputed median 1.880860% ≤5%; no rerun needed. Excludes disk/paint/layout/rasterization and other workloads. | +| C1 readiness | Two bounded modules/declarations, typed native families and old pressure v1, Session/process/custody ownership, gated subgraph/whole support, refused implicit transfer, P1/Q1 independence. | Prepared direction passes; requested G1-Accepted/C1-A-Planned canonical status correction is disposed. No C1 implementation/acceptance implied. | + +The Session remains publication owner; recording remains custody owner; numerical owners emit facts directly; UI consumes an exact cached selection. Failed source/harness evaluations are retained separately. The [qualification report](g1-qualification.md) and [manifest](g1-manifest.json) limit accepted claims to actual evidence; scientific, GPU, browser, universal accessibility and shipped-release claims remain outside scope. diff --git a/docs/plans/fluid-accuracy-composition/reports/verification.md b/docs/plans/fluid-accuracy-composition/reports/verification.md index 1a81700c..6b067881 100644 --- a/docs/plans/fluid-accuracy-composition/reports/verification.md +++ b/docs/plans/fluid-accuracy-composition/reports/verification.md @@ -13,9 +13,9 @@ Evidence-kind, environment and mode qualify each claim independently; a stronger | P0-D | Complete source-grounded proposal, all F1–F8, ownership and independent review dispositions | focused + independent design review | not-applicable / manual review | Planning owner | satisfied — independent reviews disposed and source checks passed; qualification.md | | P0-L | Selected exact statements compile with allowed axioms, negative audit rejection | focused formal contract | required-real: pinned Lean 4.19.0 + locked mathlib / automated | Proof owner | satisfied — 12 theorem statements, 25 audited declarations, 3 negative probes; qualification.md; not a physical or Rust claim | | P0-V | Authored HTML supports sample selection/keyboard/navigation | focused prototype interaction | simulated authored data in Chromium / automated | Visual author | satisfied for authored prototype only; qualification.md; product claim pending | -| G1-C | Descriptor/actual attempt/profile/legacy binding and refusal | contract + integration | representative native CPU/archive fixtures / automated | Composition + recording owners | pending — production Deferred/unimplemented | -| G1-U | Live→evidence→cancel→cold workflow inspection with pointer/keyboard | user-workflow | representative supported native display/graphics / automated or recorded manual | Desktop + verification owners | pending — production Deferred/unimplemented; HTML cannot satisfy | -| C1-R | Stage/subgraph/family replacement/rollback/provenance | contract + integration; external runtime system where used | representative CPU + actual supervised process fixture / automated | Provider + Session owners | pending — production Deferred/unimplemented | +| G1-C | Descriptor/actual attempt/profile/legacy binding and refusal | contract + integration | representative native CPU/archive fixtures / automated | Composition + recording owners | satisfied for existing configured sealed MAC — final source f6b7c8c; 15 core/workflow contracts, broad CPU regression and independent review; [qualification](g1-qualification.md) | +| G1-U | Live→evidence→cancel→cold workflow inspection with pointer/keyboard | user-workflow | representative supported native display/graphics / automated or recorded manual | Desktop + verification owners | satisfied for recorded dummy-Xorg/software-GLX/evdev environment at f6b7c8c; actual workflow and 11 focused UI cases, independent review; [qualification](g1-qualification.md) | +| C1-R | Stage/subgraph/family replacement/rollback/provenance | contract + integration; external runtime system where used | representative CPU + actual supervised process fixture / automated | Provider + Session owners | pending — C1-A Planned/dependency-ready; [bounded preparation](c1-readiness.md), no replacement acceptance yet | | P-W | Selected particle wall/EOS/kernel/initialization accuracy | focused + numerical integration | representative native CPU production path / automated | Particle + geometry owners | pending — method-selection gates not executed | | Q-S | Grid geometry/Stokes/transport flux/traction convergence | focused + numerical integration | representative native CPU production path / automated | Grid + geometry owners | pending — method-selection gates not executed | | Q-E | Capillary and wall work correctness | numerical integration | representative native CPU admitted family / automated | Physics owner | pending — production Deferred/unimplemented | @@ -31,7 +31,7 @@ Required automated checks: descriptor canonical ordering/digest stable for ident Use existing real-egui pointer/keyboard event helpers plus a native application interaction test. Actual smoke: isolated temporary state → start fixture run → open live workflow → select node with pointer and keyboard → inspect units/residual/actual provider/fallback → change accepted interval → open evidence → Cancel/Close → cold reopen archived run with provider executable unavailable → inspect same descriptor/receipts and exit. All assertions must pass; record display/session, sandbox, graphics/software rendering, resource bounds and process cleanup. Headless paint/startup-only/screenshots cannot substitute. Gate keyboard controls, visible focus, semantic labels/text list, non-color status and readable zoom; do not claim universal accessibility compliance from these checks. Proposed overhead budget: descriptor ≤1 MiB, node summaries ≤256 nodes/interval, capture+projection ≤5% median interval overhead on the same CPU fixture (30 paired intervals after warmup), bounded-memory cold inspection with no per-pair trace. If hardware timing uncertainty dominates, report unresolved budget and investigate before admission. -The HTML design aid has a separate browser check only. Native graph acceptance is still open. A future custom-browser consumer must pass equivalent bounded-data, keyboard, stale-source and receipt tests in an actual browser; native external-process/SQLite/import features remain outside that browser claim. +The HTML design aid has a separate browser check only. G1 native acceptance is now qualified in [the implementation report](g1-qualification.md); the criteria above remain unchanged. A future custom-browser consumer must pass equivalent bounded-data, keyboard, stale-source and receipt tests in an actual browser; native external-process/SQLite/import features remain outside that browser claim. ## Replacement contract gates diff --git a/src/bin/desktop/ai_comparison/configured_track.rs b/src/bin/desktop/ai_comparison/configured_track.rs index f3d026c7..70f66998 100644 --- a/src/bin/desktop/ai_comparison/configured_track.rs +++ b/src/bin/desktop/ai_comparison/configured_track.rs @@ -64,6 +64,8 @@ pub(crate) struct ConfiguredTrack { provider: Option, failure: Option, runtime_observation: Option, + attempt_id: u64, + workflow: Option>, } impl ConfiguredTrack { /// Validate exact executor custody before allocating state. No filesystem @@ -134,6 +136,8 @@ impl ConfiguredTrack { provider: None, failure: None, runtime_observation: None, + attempt_id: 0, + workflow: None, }) } @@ -213,6 +217,8 @@ impl ConfiguredTrack { ) -> Result { self.failure = None; self.runtime_observation = None; + // A pre-execution refusal must never reuse the previous attempt. + self.workflow = None; if let Some(provider) = &mut self.provider { provider.begin_interval_observation(); } @@ -245,9 +251,15 @@ impl ConfiguredTrack { dynamic_viscosity_pa_s, } => self.viscosity.as_mut().map(|w| (w, dynamic_viscosity_pa_s)), }; + self.attempt_id = self + .attempt_id + .checked_add(1) + .ok_or(SessionError::PressureBinding( + ProviderRejection::Unsupported("Workflow attempt identity exhausted"), + ))?; let result = self .session - .step_with_configured_pressure( + .step_with_configured_pressure_observed( LiquidStepInputs { requested_dt: self.frozen.future.requested_dt_s, smoke_source: None, @@ -261,6 +273,8 @@ impl ConfiguredTrack { preferred, deadline, |_| self.cancelled.load(Ordering::Relaxed), + self.attempt_id, + &mut self.workflow, ) .map(|(report, _)| report); self.runtime_observation = self @@ -303,6 +317,9 @@ impl ConfiguredTrack { ) -> Option<&rheon::recording::provider::runtime::RuntimeObservation> { self.runtime_observation.as_ref() } + pub(crate) fn workflow_attempt(&self) -> Option<&Arc> { + self.workflow.as_ref() + } #[cfg(test)] pub(crate) fn runtime_ready(&self) -> bool { self.provider.is_some() diff --git a/src/bin/desktop/comparison.rs b/src/bin/desktop/comparison.rs index 267244cd..78739713 100644 --- a/src/bin/desktop/comparison.rs +++ b/src/bin/desktop/comparison.rs @@ -77,6 +77,46 @@ pub struct SelectedNativePair<'a> { pub pair: &'a PairComparison, } +#[derive(Clone, PartialEq)] +pub struct WorkflowComparisonKey { + root: PathBuf, + run_set: [u8; 32], + recipe_sha256: [u8; 32], + analysis_version: u32, + reference_member: String, + candidate_member: String, + recipe: Recipe, + pair: PairComparison, + pub side: super::comparison_archive::Side, +} +impl WorkflowComparisonKey { + fn new( + selected: &SelectedNativePair<'_>, + side: super::comparison_archive::Side, + ) -> Result { + Ok(Self { + root: selected.root.into(), + run_set: selected.recipe.run_set_sha256, + recipe_sha256: selected + .recipe + .sha256() + .map_err(|error| SelectedFieldsError::Source(error.to_string()))?, + analysis_version: selected.recipe.analysis_version, + reference_member: selected.recipe.reference.member_id.clone(), + candidate_member: selected.recipe.candidate.member_id.clone(), + recipe: selected.recipe.recipe, + pair: selected.pair.clone(), + side, + }) + } +} +pub struct SelectedWorkflow { + pub key: WorkflowComparisonKey, + pub run: rheon::recording::RecordingRun, + pub ordinal: usize, + pub attempt: Option>, +} + #[derive(Debug)] pub enum SelectedFieldsError { Source(String), @@ -189,6 +229,75 @@ impl SavedComparisonPage { pair: result.coverage().pairs.get(self.selected)?, }) } + pub fn workflow_key( + &self, + side: super::comparison_archive::Side, + ) -> Option { + self.selected_pair() + .and_then(|selected| WorkflowComparisonKey::new(&selected, side).ok()) + } + /// Explicit action, with the same pinned source and exact selection proof as + /// native field inspection. Only compact graph facts survive the load. + pub fn load_selected_workflow( + &mut self, + side: super::comparison_archive::Side, + ) -> Result, SelectedFieldsError> { + let result = (|| { + let Some(selected) = self.selected_pair() else { + return Ok(None); + }; + let (member_id, expected) = match side { + super::comparison_archive::Side::Reference => ( + &selected.recipe.reference.member_id, + &selected.pair.reference, + ), + super::comparison_archive::Side::Candidate => ( + &selected.recipe.candidate.member_id, + &selected.pair.candidate, + ), + }; + let member = selected.recipe.run_set.member(member_id).ok_or_else(|| { + SelectedFieldsError::Source("selected workflow member missing".into()) + })?; + let reader = match member.open(selected.root) { + MemberState::Ready(reader) => reader, + MemberState::Invalid(error) => { + return Err(SelectedFieldsError::Source(error.to_string())); + } + MemberState::Unsupported(reason) => { + return Err(SelectedFieldsError::Source(reason.into())); + } + _ => { + return Err(SelectedFieldsError::Source( + "exact workflow archive is unavailable or not pinned".into(), + )); + } + }; + let source = Selection::read(&reader, expected.ordinal) + .map_err(|error| SelectedFieldsError::Source(error.to_string()))?; + if source.identity() != expected { + return Err(SelectedFieldsError::Source( + "exact selected workflow publication changed".into(), + )); + } + let attempt = if source.run().solver == rheon::recording::provider::PROFILE { + rheon::recording::workflow::selection(&reader, source.publication()) + .map_err(|error| SelectedFieldsError::Source(error.to_string()))? + } else { + None + }; + Ok(Some(SelectedWorkflow { + key: WorkflowComparisonKey::new(&selected, side)?, + run: source.run().clone(), + ordinal: expected.ordinal, + attempt, + })) + })(); + if let Err(error) = &result { + self.fail(error); + } + result + } /// 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. @@ -699,10 +808,14 @@ impl SavedComparisonPage { ui.label("No matched physical publications. No numerical metric is available."); return; } - ui.add( + let response = ui.add( egui::Slider::new(&mut self.selected, 0..=coverage.pairs.len() - 1) .text("Matched pair"), ); + #[cfg(test)] + self.controls.insert("matched-pair", response.rect); + #[cfg(not(test))] + let _ = response; let pair = &coverage.pairs[self.selected]; ui.label(format!( "Reference ordinal {}, generation {} · candidate ordinal {}, generation {}", diff --git a/src/bin/desktop/inspector.rs b/src/bin/desktop/inspector.rs index f6e0b150..20fa26f8 100644 --- a/src/bin/desktop/inspector.rs +++ b/src/bin/desktop/inspector.rs @@ -1,9 +1,7 @@ //! Exact selected disk publication. The worker owns recording; UI owns selection. use super::export_ui::ExportFile; use eframe::egui; -use rheon::recording::provider::{ - RecordedConfiguredSession, configured_publication_with_runtime, configured_run, -}; +use rheon::recording::provider::{RecordedConfiguredSession, configured_run}; use rheon::recording::{ RecordedCarrier, RecordedMaterial, RecordedStep, RecordingLimits, RecordingReader, StepRecorder, carrier_publication, carrier_run, material_publication, material_run, @@ -26,6 +24,7 @@ struct State { carrier: Option>, configured: Option>, record: Option>, + workflow: Option>, field_name: String, scalar_index: usize, selected: usize, @@ -44,6 +43,7 @@ impl Default for State { carrier: None, configured: None, record: None, + workflow: None, field_name: String::new(), scalar_index: 0, selected: 0, @@ -53,6 +53,11 @@ impl Default for State { } } } +pub struct WorkflowSelection { + pub run: rheon::recording::RecordingRun, + pub step: Arc, + pub attempt: Option>, +} #[derive(Default)] pub struct Inspector(Mutex); impl Inspector { @@ -69,7 +74,7 @@ impl Inspector { "Configured constructor/header changed; accepted interval unrecorded".into(), ); } - let step = configured_publication_with_runtime( + let step = rheon::recording::workflow::configured_publication_with_workflow( snapshot, recorder.recorded_count(), recorder.limits().max_record_bytes, @@ -84,6 +89,41 @@ impl Inspector { })?; Ok(()) } + /// Immutable selected publication for the workflow view. This performs no + /// I/O and never substitutes a newer publication when selection is absent. + pub fn workflow_selection(&self) -> Result, String> { + let s = self.0.lock().map_err(|_| "Inspector owner failed")?; + if s.selected_step.is_some() && s.record.is_none() && !s.feedback.is_empty() { + return Err(s.feedback.clone()); + } + Ok(match (&s.reader, &s.record) { + (Some(reader), Some(record)) => Some(WorkflowSelection { + run: reader.run().clone(), + step: Arc::clone(record), + attempt: s.workflow.clone(), + }), + _ => None, + }) + } + pub fn workflow_identity(&self) -> Option { + self.0 + .lock() + .ok()? + .reader + .as_ref() + .map(|reader| reader.run().identity.clone()) + } + /// Explicit terminal-evidence load. Call only on a user load/refresh action, + /// then retain the immutable result for paint. + pub fn load_workflow_terminal( + &self, + ) -> Result>, String> { + let s = self.0.lock().map_err(|_| "Inspector owner failed")?; + let reader = s.reader.as_ref().ok_or("No archive selected")?; + rheon::recording::workflow::terminal(reader) + .map(|audit| audit.and_then(|audit| audit.attempt)) + .map_err(|error| error.to_string()) + } #[cfg(test)] pub fn selected_identity(&self) -> Option<(u64, u64, f64)> { let s = self.0.lock().ok()?; @@ -214,6 +254,7 @@ impl Inspector { s.material = None; s.carrier = None; s.configured = None; + s.workflow = None; s.feedback = e.clone(); } result @@ -223,10 +264,16 @@ impl Inspector { let reader = s.reader.as_ref().ok_or("No archive selected")?; let result = if reader.run().solver == rheon::recording::provider::PROFILE { match reader.status() { + Ok(status) if status.detail.starts_with("RCT2:") => { + rheon::recording::workflow::terminal(reader).map(|_| ()) + } Ok(status) if status.detail.starts_with("RCT1:") => { rheon::recording::provider::runtime::audit_runtime_termination(reader) .map(|_| ()) } + Ok(status) if status.detail.starts_with("RCT") => Err( + rheon::recording::RecordingError::Unsupported("runtime termination version"), + ), Ok(_) => { rheon::recording::provider::verify_configured_recording(reader).map(|_| ()) } @@ -241,6 +288,7 @@ impl Inspector { s.material = None; s.carrier = None; s.configured = None; + s.workflow = None; s.feedback = e.clone(); } result @@ -252,6 +300,7 @@ impl Inspector { s.material = None; s.carrier = None; s.configured = None; + s.workflow = None; s.record = None; s.path = None; s.field_name.clear(); @@ -312,6 +361,7 @@ impl Inspector { s.material = None; s.carrier = None; s.configured = None; + s.workflow = None; s.record = None; let result = s .reader @@ -342,10 +392,20 @@ impl Inspector { } else { None }; - Ok((Arc::new(record), material, carrier, configured)) + let workflow = if run.solver == rheon::recording::provider::PROFILE { + rheon::recording::workflow::selection( + s.reader.as_ref().ok_or("No archive selected")?, + &record, + ) + .map_err(|e| e.to_string())? + } else { + None + }; + Ok((Arc::new(record), material, carrier, configured, workflow)) }); match result { - Ok((record, material, carrier, configured)) => { + Ok((record, material, carrier, configured, workflow)) => { + s.workflow = workflow; s.material = material; s.carrier = carrier; s.configured = configured; @@ -406,7 +466,8 @@ impl Inspector { match reader.status() { Ok(status) => { if reader.run().solver == rheon::recording::provider::PROFILE - && status.detail.starts_with("RCT1:") + && (status.detail.starts_with("RCT1:") + || status.detail.starts_with("RCT2:")) { match rheon::recording::provider::runtime::RuntimeTermination::decode( reader.run(), @@ -422,6 +483,12 @@ impl Inspector { ui.label(error.to_string()); } } + } else if reader.run().solver == rheon::recording::provider::PROFILE + && status.detail.starts_with("RCT") + { + let error = "Unsupported runtime termination version".to_string(); + ui.label(&error); + metadata_error = Some(error); } else { ui.small(format!("{}: {}", status.outcome, status.detail)); } @@ -440,6 +507,7 @@ impl Inspector { s.material = None; s.carrier = None; s.configured = None; + s.workflow = None; s.feedback = error; } if !s.feedback.is_empty() { diff --git a/src/bin/desktop/run_sets/configured/archive.rs b/src/bin/desktop/run_sets/configured/archive.rs index 671da9c7..a512aa75 100644 --- a/src/bin/desktop/run_sets/configured/archive.rs +++ b/src/bin/desktop/run_sets/configured/archive.rs @@ -64,7 +64,7 @@ pub fn runtime_custody( verify_configured_recording(reader)?; return Ok(ConfiguredRuntimeCustody::LegacyUnavailable); }; - if version != "1" { + if version != "1" && version != "2" { return Err(RunSetError::Unsupported( "configured runtime termination version", )); @@ -74,7 +74,27 @@ pub fn runtime_custody( "configured runtime termination payload", )); } - let audit = audit_runtime_termination(reader)?; + let workflow = if version == "2" { + Some( + rheon::recording::workflow::terminal(reader)?.ok_or(RunSetError::Integrity( + "configured workflow terminal absent", + ))?, + ) + } else { + None + }; + let audit = if let Some(workflow) = &workflow { + workflow.runtime.clone() + } else { + audit_runtime_termination(reader)? + }; + if let Some(attempt) = workflow + .as_ref() + .and_then(|workflow| workflow.attempt.as_ref()) + { + validate_workflow_intent(attempt, spec)?; + } + let future = &spec.future; if audit.termination.accepted_generation > future.requested_steps as u64 { return Err(RunSetError::Integrity( @@ -128,6 +148,63 @@ pub fn runtime_custody( Ok(ConfiguredRuntimeCustody::Typed(Box::new(audit))) } +fn validate_workflow_intent( + attempt: &rheon::workflow::WorkflowAttempt, + spec: &FrozenConfiguredSpec, +) -> Result<(), RunSetError> { + if attempt.attempted_generation > spec.future.requested_steps as u64 + || attempt.requested_dt.to_bits() != spec.future.requested_dt_s.to_bits() + || attempt.descriptor.viscosity.is_some() != declared_viscosity(spec).0 + || attempt.descriptor.viscosity.unwrap_or(0.0).to_bits() + != declared_viscosity(spec).1.to_bits() + { + return Err(RunSetError::Integrity( + "configured workflow attempt differs from intent", + )); + } + let forces = spec + .future + .forces + .iter() + .map(super::ForceSpec::body_force) + .collect::>(); + let inlet_id = (0..=2) + .find(|id| *id != spec.constructor.carrier_id && *id != spec.constructor.volume_stamp.id) + .ok_or(RunSetError::Integrity("configured zero inlet identity"))?; + let inlet = rheon::LiquidInlet::new( + rheon::VolumeStamp { + id: inlet_id, + version: 0, + }, + [[0.0; 2]; 3], + ) + .map_err(|_| RunSetError::Integrity("configured zero inlet"))?; + if !attempt.matches_request(rheon::LiquidStepInputs { + requested_dt: spec.future.requested_dt_s, + smoke_source: None, + forces: &forces, + inlet, + source: None, + volume: spec.future.volume.settings(), + }) { + return Err(RunSetError::Integrity( + "configured workflow actual controls differ from intent", + )); + } + if let Some(actual) = attempt + .pressure + .as_ref() + .and_then(|pressure| pressure.binding.as_ref()) + { + expected_provider(spec, attempt.attempted_generation) + .ok_or(RunSetError::Integrity( + "configured workflow provider in native phase", + ))? + .validate_resolved_identity(actual)?; + } + Ok(()) +} + fn declared_viscosity(spec: &FrozenConfiguredSpec) -> (bool, f64) { match spec.future.viscosity { ViscosityIntent::Disabled => (false, 0.0), @@ -348,6 +425,9 @@ fn validate_publications( for ordinal in 0..count { let step = reader.select(ordinal)?; let observed = RecordedConfiguredSession::decode(reader.run(), &step)?; + if let Some(attempt) = rheon::recording::workflow::publication(reader.run(), &step)? { + validate_workflow_intent(&attempt, spec)?; + } let initial = future.capture_constructor && ordinal == 0; let generation = (ordinal + 1 - constructor_count) as u64; if observed.generation != generation diff --git a/src/bin/desktop/workflow_inspector.rs b/src/bin/desktop/workflow_inspector.rs new file mode 100644 index 00000000..26671be0 --- /dev/null +++ b/src/bin/desktop/workflow_inspector.rs @@ -0,0 +1,769 @@ +//! Cached, read-only native projection of numerical workflow receipts. +//! Selection and explicit archive reads happen before paint; rendering never +//! starts a provider, reads a package, or opens a recording. +use super::inspector::Inspector; +use eframe::egui; +use rheon::{ + recording::{RecordedStep, RecordingRun}, + workflow::{EdgeKind, NodeKind, NodeStatus, WorkflowAttempt}, +}; +use std::sync::Arc; + +const LANES: [&str; 5] = [ + "Geometry", + "State", + "Algorithm", + "Qualification", + "Publication", +]; +const NODE_SIZE: egui::Vec2 = egui::vec2(174.0, 58.0); + +#[derive(Default)] +pub struct WorkflowInspector { + pub open: bool, + source: Option<(RecordingRun, Arc)>, + attempt: Option>, + message: String, + selected: usize, + zoom: f32, + pan: egui::Vec2, + pan_drag: Option<(egui::Pos2, egui::Vec2)>, + evidence: bool, + restore_focus: bool, + terminal_request: bool, + terminal_mode: bool, + identity: Option, + return_focus: Option, + evidence_focus: Option, + navigated: bool, + focus_text: bool, + comparison_key: Option, + comparison_source: Option<(RecordingRun, usize)>, + comparison_context: bool, + #[cfg(test)] + controls: std::collections::BTreeMap, +} +impl WorkflowInspector { + /// Consumes only an immutable cached exact publication, including clearing + /// the previous graph when the upstream selection becomes unavailable. + pub fn select(&mut self, inspector: Option<&Inspector>) { + if self.comparison_context { + self.terminal_request = false; + self.terminal_mode = false; + } + self.comparison_key = None; + self.comparison_source = None; + self.comparison_context = false; + let identity = inspector.and_then(Inspector::workflow_identity); + let identity_changed = self.identity != identity; + self.identity = identity; + let cached = inspector.map_or(Ok(None), Inspector::workflow_selection); + let attempt = cached + .as_ref() + .ok() + .and_then(|s| s.as_ref()) + .and_then(|selection| selection.attempt.clone()); + let selection = + cached.map(|selection| selection.map(|selection| (selection.run, selection.step))); + if self.terminal_request { + self.terminal_request = false; + let source = selection.as_ref().ok().and_then(|s| s.as_ref()).cloned(); + self.terminal_mode = true; + match inspector + .ok_or_else(|| "No recorded source selected".to_string()) + .and_then(Inspector::load_workflow_terminal) + { + Ok(Some(attempt)) => self.set_attempt(attempt), + Ok(None) => self.unavailable( + "Terminal workflow execution evidence unavailable (active or legacy archive).", + ), + Err(error) => self.unavailable(&format!( + "Invalid terminal workflow execution evidence: {error}" + )), + } + self.source = source; + return; + } + if self.terminal_mode && !identity_changed { + let unchanged = match (selection.as_ref(), self.source.as_ref()) { + (Ok(None), None) => true, + (Ok(Some((run, step))), Some((old_run, old_step))) => { + old_run == run && Arc::ptr_eq(old_step, step) + } + _ => false, + }; + if unchanged { + return; + } + } + self.terminal_mode = false; + match selection { + Ok(Some((run, step))) => { + if self.source.as_ref().is_some_and(|(old_run, old_step)| { + old_run == &run && Arc::ptr_eq(old_step, &step) + }) { + return; + } + self.attempt = None; + self.message.clear(); + self.evidence = false; + match attempt { + Some(attempt) => self.set_attempt(attempt), + None => self.message = "Workflow execution evidence unavailable for this publication (constructor or legacy archive).".into(), + } + self.source = Some((run, step)); + } + Ok(None) => self.unavailable( + "Workflow execution evidence unavailable: no exact recorded publication selected.", + ), + Err(error) => self.unavailable(&format!("Workflow selection unavailable: {error}")), + } + } + fn unavailable(&mut self, message: &str) { + self.pan_drag = None; + self.source = None; + self.attempt = None; + self.evidence = false; + self.message = message.into(); + } + pub fn load_comparison( + &mut self, + loaded: Result< + Option, + super::saved_comparison::SelectedFieldsError, + >, + ) { + self.unavailable( + "Workflow execution evidence unavailable: choose a verified comparison publication.", + ); + self.comparison_key = None; + self.comparison_source = None; + self.comparison_context = true; + self.terminal_request = false; + self.terminal_mode = false; + match loaded { + Ok(Some(loaded)) => { + self.comparison_key = Some(loaded.key); + self.comparison_source = Some((loaded.run, loaded.ordinal)); + match loaded.attempt {Some(attempt)=>self.set_attempt(attempt),None=>self.message="Workflow execution evidence unavailable for this comparison publication (constructor or legacy archive).".into()} + } + Ok(None) => {} + Err(error) => { + self.message = + format!("Invalid or unavailable selected comparison workflow: {error}") + } + } + } + pub fn comparison_side(&self) -> Option { + self.comparison_key.as_ref().map(|key| key.side) + } + pub fn validate_comparison( + &mut self, + current: Option<&super::saved_comparison::WorkflowComparisonKey>, + ) { + if !self.comparison_context + || (self.comparison_key.is_some() && self.comparison_key.as_ref() != current) + { + self.unavailable("Workflow execution evidence unavailable: comparison source, recipe or selected pair changed; inspect the exact side again."); + self.comparison_key = None; + self.comparison_source = None; + self.comparison_context = true; + } + } + fn set_attempt(&mut self, attempt: Arc) { + self.pan_drag = None; + match attempt.validate() { + Ok(()) => { + self.selected = self.selected.min(attempt.descriptor.nodes().len() - 1); + self.attempt = Some(attempt); + self.message.clear(); + if self.zoom == 0.0 { + self.reset(); + } + } + Err(error) => { + self.unavailable(&format!("Invalid workflow execution evidence: {error}")) + } + } + } + pub fn open(&mut self) { + self.pan_drag = None; + self.open = true; + self.focus_text = false; + self.restore_focus = true; + } + pub fn open_from(&mut self, trigger: egui::Id) { + self.open(); + self.return_focus = Some(trigger); + } + fn reset(&mut self) { + self.pan_drag = None; + self.zoom = 0.85; + self.pan = egui::Vec2::ZERO; + } + fn control(&mut self, name: impl Into, response: egui::Response) -> egui::Response { + #[cfg(test)] + self.controls.insert(name.into(), response.rect); + #[cfg(not(test))] + let _ = name; + response + } + pub fn show(&mut self, ctx: &egui::Context) { + if !self.open { + self.pan_drag = None; + return; + } + if ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + if self.evidence { + self.evidence = false; + if let Some(id) = self.evidence_focus { + ctx.memory_mut(|memory| memory.request_focus(id)); + } + } else { + self.open = false; + if let Some(id) = self.return_focus { + ctx.memory_mut(|memory| memory.request_focus(id)); + } + return; + } + } + let mut open = true; + egui::Window::new("Executed workflow") + .id(egui::Id::new("executed-workflow")) + .open(&mut open) + .default_width(1100.0) + .default_height(700.0) + .resizable(true) + .show(ctx, |ui| self.ui(ui)); + if !open { + self.open = false; + self.evidence = false; + } + if self.evidence { + let mut open = true; + egui::Window::new("Retained workflow evidence") + .id(egui::Id::new("retained-workflow-evidence")) + .open(&mut open) + .default_width(640.0) + .show(ctx, |ui| { + ui.label("Exact retained attempt; no provider execution is needed for inspection."); + let scroll = egui::ScrollArea::vertical() + .id_salt("workflow-evidence-scroll") + .max_height((ctx.content_rect().bottom() - ui.cursor().top() - 60.0).clamp(80.0, 600.0)) + .show(ui, |ui| { + if let Some(attempt) = &self.attempt { + ui.monospace(format!("descriptor SHA-256 {}", hex(&attempt.descriptor_sha256))); + ui.monospace(format!("Accepted native input state SHA-256 {}", hex(&attempt.input_sha256))); + ui.monospace(format!("Actual request controls SHA-256 {}", hex(&attempt.request_sha256))); + ui.label(format!("{} prescribed forces · smoke source {} · volume source {}", attempt.force_count, attempt.smoke_source_present, attempt.volume_source_present)); + ui.label(format!("Run {} · attempt {} · publication {:?}", attempt.run_id, attempt.attempt_id, attempt.publication)); + ui.label(&attempt.diagnostic); + if let Some((run, step)) = &self.source { + ui.label(format!("Source {} · recorded ordinal {}", run.identity.source_revision, step.ordinal)); + ui.label(&run.provenance); + } + if let Some((run, ordinal)) = &self.comparison_source { + ui.label(format!("Verified comparison source {} · recorded ordinal {}", run.identity.source_revision, ordinal)); + ui.label(&run.provenance); + } + if let Some(pressure) = &attempt.pressure { + if let Some(binding) = &pressure.binding { + ui.label(format!("Provider binding: {} / {}", binding.id, binding.version)); + ui.monospace(format!("Provider manifest SHA-256 {}", hex(&binding.manifest_sha256))); + ui.monospace(format!("Provider checkpoint SHA-256 {}", binding.checkpoint_sha256.map_or_else(|| "unavailable".into(), |hash| hex(&hash)))); + ui.label(format!("Provider runtime {} · source {} · training run {}", binding.runtime, binding.source_revision, binding.training_run_id.as_deref().unwrap_or("unavailable"))); + } else { + ui.label("Configured provider binding unavailable; native selection is recorded separately."); + } + if let Some(hash) = pressure.rhs_sha256 { ui.monospace(format!("Input RHS SHA-256 {}", hex(&hash))); } + } + } + }); + #[cfg(test)] + self.controls.insert("evidence-scroll".into(), scroll.inner_rect); + #[cfg(not(test))] + let _ = scroll; + let close = ui.button("Close evidence"); + if self.control("close-evidence", close).clicked() { self.evidence = false; } + }); + self.evidence &= open; + if !self.evidence + && let Some(id) = self.evidence_focus + { + ctx.memory_mut(|memory| memory.request_focus(id)); + } + } + if !self.open + && let Some(id) = self.return_focus + { + ctx.memory_mut(|memory| memory.request_focus(id)); + } + } + fn ui(&mut self, ui: &mut egui::Ui) { + self.navigated = false; + #[cfg(test)] + self.controls.clear(); + ui.label("Read-only native sealed MAC composition and actual execution receipts"); + if let Some(key) = &self.comparison_key { + ui.label(format!("Verified comparison side: {:?}", key.side)); + } + ui.horizontal(|ui| { + if self.comparison_context { ui.small("Exact verified comparison publication. Inspect its archive for terminal attempts."); } + else { + if self + .control("terminal", ui.button("Load terminal attempt")) + .clicked() + { + self.terminal_request = true; + } + if self + .control("publication", ui.button("Inspect selected publication")) + .clicked() + { + self.source = None; + self.terminal_mode = false; + } + } + }); + if !self.message.is_empty() { + ui.label(&self.message); + } + let Some(attempt) = self.attempt.clone() else { + if ui.button("Close workflow").clicked() { + self.open = false; + } + return; + }; + ui.horizontal_wrapped(|ui| { + ui.label(format!( + "Run {} · attempt {} · {:?}", + attempt.run_id, attempt.attempt_id, attempt.outcome + )); + ui.label(format!( + "Input generation {} → attempted {} · publication {:?}", + attempt.accepted_generation, attempt.attempted_generation, attempt.publication + )); + }); + if let Some(pressure) = &attempt.pressure { + ui.label(format!( + "Actual pressure provider: {} · fallback/rejection: {}", + if pressure.selected.is_empty() { + "unavailable" + } else { + &pressure.selected + }, + pressure.rejection.as_deref().unwrap_or("none recorded") + )); + } + ui.horizontal_wrapped(|ui| { + let response = + ui.add(egui::Slider::new(&mut self.zoom, 0.5..=1.5).text("Workflow zoom")); + self.control("zoom", response); + let response = ui.button("Reset workflow view"); + if self.control("reset", response).clicked() { + self.reset(); + } + let response = ui.button("Open retained evidence"); + let response = self.control("evidence", response); + if response.clicked() { + self.evidence = true; + self.evidence_focus = Some(response.id); + } + let response = ui.button("Close workflow"); + if self.control("close", response).clicked() { + self.open = false; + self.evidence = false; + } + }); + ui.small("Select a node with the pointer or Tab. Arrow keys / Home / End move the focused workflow selection. Drag background to pan; use zoom or Reset. Escape closes this view."); + self.canvas(ui, &attempt); + ui.separator(); + egui::ScrollArea::vertical() + .id_salt("workflow-details") + .max_height(300.0) + .show(ui, |ui| { + self.details(ui, &attempt); + egui::CollapsingHeader::new("Pressure iteration loop details").show(ui, |ui| { + ui.label(format!("Native iteration cap {}", attempt.descriptor.pressure.max_iterations)); + if let Some(report) = attempt.pressure.as_ref().and_then(|p| p.accepted) { + ui.label(format!("Accepted pressure iterations: {}", report.iterations)); + } else { ui.label("Actual iteration count unavailable"); } + ui.small("Bounded summary only. Per-iteration residual history and whole-workflow timing are unavailable."); + }); + egui::CollapsingHeader::new( + "Ordered text equivalent: nodes and typed dependencies", + ) + .default_open(true) + .show(ui, |ui| { + for (index, node) in attempt.descriptor.nodes().iter().copied().enumerate() { + let response = ui.scope_builder(egui::UiBuilder::new().id(("workflow-text-node", node as u8)), |ui| ui.selectable_label( + self.selected == index, + format!( + "{} · {} · {}", + index + 1, + node.label(), + status(&attempt, node) + ), + )).inner; + let response = self.control(format!("text-{index}"), response); + if self.restore_focus && self.focus_text && self.selected == index { + response.request_focus();response.scroll_to_me_animation(Some(egui::Align::Center), egui::style::ScrollAnimation::none());self.restore_focus=false; + } + if response.clicked() || (response.gained_focus() && !self.navigated) { + self.selected = index;self.focus_text=true; + if response.clicked(){response.request_focus();} + } + self.keyboard(ui, &response, attempt.descriptor.nodes().len()); + } + for edge in attempt.descriptor.edges() { + ui.small(format!( + "{} → {} · {:?} · {:?}, {} · {}", + edge.from.label(), + edge.to.label(), + edge.kind, + edge.port, + edge.port.units(), + edge.port.association() + )); + } + }); + }); + } + fn keyboard(&mut self, ui: &egui::Ui, response: &egui::Response, count: usize) { + if !response.has_focus() || self.navigated { + return; + } + ui.memory_mut(|memory| { + memory.set_focus_lock_filter( + response.id, + egui::EventFilter { + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + tab: false, + }, + ) + }); + ui.memory_mut(|memory| memory.move_focus(egui::FocusDirection::None)); + let previous = self.selected; + ui.input_mut(|input| { + if input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) + || input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) + { + self.selected = (self.selected + 1).min(count - 1); + } + if input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) + || input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) + { + self.selected = self.selected.saturating_sub(1); + } + if input.consume_key(egui::Modifiers::NONE, egui::Key::Home) { + self.selected = 0; + } + if input.consume_key(egui::Modifiers::NONE, egui::Key::End) { + self.selected = count - 1; + } + if input.consume_key(egui::Modifiers::NONE, egui::Key::Escape) { + self.open = false; + self.evidence = false; + } + }); + self.restore_focus |= self.selected != previous; + self.navigated = self.selected != previous; + } + fn canvas(&mut self, ui: &mut egui::Ui, attempt: &WorkflowAttempt) { + let size = egui::vec2(ui.available_width().max(300.0), 390.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::drag()); + let response = self.control("canvas", response); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 5.0, ui.visuals().extreme_bg_color); + let position = |node: NodeKind| { + let rank = match node { + NodeKind::Geometry | NodeKind::AcceptedState => 0, + NodeKind::Interval => 1, + NodeKind::Advection => 2, + NodeKind::Forces => 3, + NodeKind::Viscosity => 4, + NodeKind::PressureRequest => 5, + NodeKind::PressureCandidate => 6, + NodeKind::PressureQualification | NodeKind::NativeFallback => 7, + NodeKind::Projection => 8, + NodeKind::TracerTransport => 9, + NodeKind::VolumeTransport => 10, + NodeKind::Publication => 11, + }; + egui::pos2(node.lane() as f32 * 200.0 + 12.0, rank as f32 * 72.0 + 32.0) + }; + if response.drag_started() { + self.pan_drag = ui + .input(|input| input.pointer.press_origin()) + .filter(|origin| { + rect.contains(*origin) + && !attempt.descriptor.nodes().iter().any(|node| { + egui::Rect::from_min_size( + rect.min + self.pan + position(*node).to_vec2() * self.zoom, + (NODE_SIZE * self.zoom).max(egui::vec2(80.0, 36.0)), + ) + .contains(*origin) + }) + }) + .map(|origin| (origin, self.pan)); + } + if response.dragged() { + if let Some(((origin, initial_pan), pointer)) = + self.pan_drag.zip(response.interact_pointer_pos()) + { + self.pan = (initial_pan + (pointer - origin)) + .clamp(egui::vec2(-1500.0, -1500.0), egui::vec2(1500.0, 1500.0)); + } + } else { + self.pan_drag = None; + } + if self.restore_focus && !self.focus_text { + let node_position = position(attempt.descriptor.nodes()[self.selected]).to_vec2() + * self.zoom + + self.pan; + if node_position.x < 0.0 { + self.pan.x -= node_position.x; + } + if node_position.x + NODE_SIZE.x * self.zoom > rect.width() { + self.pan.x -= node_position.x + NODE_SIZE.x * self.zoom - rect.width(); + } + if node_position.y < 0.0 { + self.pan.y -= node_position.y; + } + if node_position.y + NODE_SIZE.y * self.zoom > rect.height() { + self.pan.y -= node_position.y + NODE_SIZE.y * self.zoom - rect.height(); + } + } + let zoom = self.zoom; + let pan = self.pan; + let transform = |p: egui::Pos2| rect.min + pan + p.to_vec2() * zoom; + let mut graph = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + graph.set_clip_rect(rect); + for (lane, label) in LANES.iter().enumerate() { + painter.text( + transform(egui::pos2(lane as f32 * 200.0 + 12.0, 10.0)), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(13.0), + ui.visuals().text_color(), + ); + } + for edge in attempt.descriptor.edges() { + let a = transform(position(edge.from) + NODE_SIZE * 0.5); + let b = transform(position(edge.to) + NODE_SIZE * 0.5); + let color = match edge.kind { + EdgeKind::Fallback => egui::Color32::from_rgb(235, 175, 80), + EdgeKind::TimeLoop | EdgeKind::IterationLoop => { + egui::Color32::from_rgb(150, 165, 220) + } + EdgeKind::Data => ui.visuals().weak_text_color(), + }; + if edge.from == edge.to { + let start = a + egui::vec2(NODE_SIZE.x * 0.5, 0.0) * self.zoom; + let outer = start + egui::vec2(35.0, 0.0) * self.zoom; + let top = outer + egui::vec2(0.0, -55.0) * self.zoom; + let inner = egui::pos2(a.x, top.y); + let end = a - egui::vec2(0.0, NODE_SIZE.y * 0.5) * self.zoom; + painter.add(egui::Shape::line( + vec![start, outer, top, inner], + egui::Stroke::new(1.0, color), + )); + painter.arrow(inner, end - inner, egui::Stroke::new(1.0, color)); + painter.text( + outer + egui::vec2(4.0, -20.0), + egui::Align2::LEFT_CENTER, + "pressure iteration loop", + egui::FontId::proportional(10.0), + color, + ); + } else { + painter.arrow(a, b - a, egui::Stroke::new(1.0, color)); + } + painter.circle_filled(a, 2.5, color); + painter.circle_filled(b, 2.5, color); + let selected = attempt.descriptor.nodes()[self.selected]; + if edge.from != edge.to && (edge.from == selected || edge.to == selected) { + painter.text( + a.lerp(b, 0.5), + egui::Align2::CENTER_CENTER, + format!("{:?} · {} · {:?}", edge.port, edge.port.units(), edge.kind), + egui::FontId::proportional(10.0), + color, + ); + } + } + for (index, node) in attempt.descriptor.nodes().iter().copied().enumerate() { + let node_rect = egui::Rect::from_min_size( + transform(position(node)), + (NODE_SIZE * self.zoom).max(egui::vec2(80.0, 36.0)), + ); + if !node_rect.intersects(rect) { + continue; + } + let response = graph + .scope_builder( + egui::UiBuilder::new().id(("workflow-canvas-node", node as u8)), + |ui| { + ui.put( + node_rect, + egui::Button::new( + egui::RichText::new(format!( + "{}\n{}", + node.label(), + status(attempt, node) + )) + .size((13.0 * self.zoom).max(10.0)), + ) + .wrap_mode(egui::TextWrapMode::Truncate) + .selected(self.selected == index), + ) + }, + ) + .inner; + let response = self.control(format!("node-{index}"), response); + if self.restore_focus && !self.focus_text && self.selected == index { + response.request_focus(); + self.restore_focus = false; + } + if response.clicked() || (response.gained_focus() && !self.navigated) { + self.selected = index; + self.focus_text = false; + if response.clicked() { + response.request_focus(); + } + } + self.keyboard(ui, &response, attempt.descriptor.nodes().len()); + if response.has_focus() { + painter.rect_stroke( + node_rect.expand(3.0), + 3.0, + egui::Stroke::new(2.0, ui.visuals().text_color()), + egui::StrokeKind::Inside, + ); + } + } + } + fn details(&self, ui: &mut egui::Ui, attempt: &WorkflowAttempt) { + let node = attempt.descriptor.nodes()[self.selected]; + ui.heading(format!("{} · {}", node.label(), status(attempt, node))); + ui.label(format!( + "Requested dt {:.9e} s · actual dt {} · interval start {:.9e} s", + attempt.requested_dt, + number(attempt.actual_dt, "s"), + attempt.interval_start + )); + ui.small(format!( + "{} · {}", + attempt.descriptor.family(), + attempt.descriptor.operator() + )); + ui.label(format!( + "Density {} kg/m³ · viscosity {}", + attempt.descriptor.density, + attempt + .descriptor + .viscosity + .map_or_else(|| "disabled".into(), |v| format!("{v:.9e} Pa·s (applied)")) + )); + ui.label(format!( + "Grid {:?} · spacing {:?} m · origin {:?} m", + attempt.descriptor.grid.counts(), + attempt.descriptor.grid.spacing(), + attempt.descriptor.grid.origin() + )); + for edge in attempt + .descriptor + .edges() + .iter() + .filter(|e| e.from == node || e.to == node) + { + ui.small(format!( + "{} → {}: {:?} · {} · {} · {:?}", + edge.from.label(), + edge.to.label(), + edge.port, + edge.port.units(), + edge.port.association(), + edge.kind + )); + } + if let Some(pressure) = &attempt.pressure { + ui.label(format!( + "Actual pressure provider: {} · support {:?} · provider method entered {}", + if pressure.selected.is_empty() { + "unavailable" + } else { + &pressure.selected + }, + pressure.support, + pressure.prediction_called + )); + ui.label(format!( + "Candidate rejection / fallback reason: {}", + pressure.rejection.as_deref().unwrap_or("none recorded") + )); + ui.label(format!( + "Actual host L2 residual acceptance limit: {}", + number(pressure.residual_l2_limit, "m³/s²") + )); + if let Some(report) = pressure.candidate { + ui.label(format!("Candidate pressure (before host selection): residual L2 {:.6e} m³/s² · max {:.6e} m³/s² · predicted divergence {:.6e} 1/s · iterations {}", report.true_residual_l2, report.true_residual_max, report.predicted_divergence_max, report.iterations)); + } else { + ui.label("Candidate pressure diagnostics unavailable"); + } + if let Some(report) = pressure.accepted { + ui.label(format!("Accepted pressure (selected provider): residual L2 {:.6e} m³/s² · max {:.6e} m³/s² · predicted divergence {:.6e} 1/s · iterations {}", report.true_residual_l2, report.true_residual_max, report.predicted_divergence_max, report.iterations)); + } else { + ui.label("Accepted pressure diagnostics unavailable"); + } + ui.label(format!( + "Pressure-call time {}", + pressure.pressure_call_ns.map_or_else( + || "unavailable".into(), + |n| format!("{n} ns (pressure call only)") + ) + )); + } else { + ui.label("Pressure execution evidence unavailable for this attempt"); + } + ui.label(format!("Pressure limits: relative residual {:.6e} (dimensionless) · absolute residual {:.6e} m³/s² · predicted divergence {:.6e} 1/s · iteration cap {}", attempt.descriptor.pressure.relative_residual, attempt.descriptor.pressure.absolute_residual, attempt.descriptor.pressure.divergence_limit, attempt.descriptor.pressure.max_iterations)); + ui.label(format!( + "Actual divergence {} · limit {:.6e} 1/s", + number(attempt.actual_divergence_max, "1/s"), + attempt.descriptor.actual_divergence_limit + )); + ui.label(&attempt.diagnostic); + } +} +fn status(attempt: &WorkflowAttempt, node: NodeKind) -> &'static str { + match attempt + .nodes + .iter() + .rev() + .find(|receipt| receipt.node == node) + .map(|r| r.status) + { + Some(NodeStatus::Started) => "incomplete: started", + Some(NodeStatus::Completed) => "✓ completed", + Some(NodeStatus::CandidateReturned) => "candidate returned", + Some(NodeStatus::Rejected) => "! rejected", + Some(NodeStatus::Selected) => "→ selected", + Some(NodeStatus::Accepted) => "✓ accepted", + None => "execution unavailable", + } +} +fn number(value: Option, units: &str) -> String { + value.map_or_else( + || "unavailable".into(), + |value| format!("{value:.9e} {units}"), + ) +} +fn hex(bytes: &[u8; 32]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +#[path = "workflow_inspector_tests.rs"] +mod tests; diff --git a/src/bin/desktop/workflow_inspector_tests.rs b/src/bin/desktop/workflow_inspector_tests.rs new file mode 100644 index 00000000..fbfb45e7 --- /dev/null +++ b/src/bin/desktop/workflow_inspector_tests.rs @@ -0,0 +1,1109 @@ +use super::*; + +fn fixture() -> Arc { + observed_fixture(false).0 +} +fn observed_fixture(cancel_before: bool) -> (Arc, rheon::SimulationSession) { + use rheon::*; + const CAP: usize = 4 * 1024 * 1024; + let grid = GridGeometry::new([2; 3], [0.25; 3], [0.0; 3]).unwrap(); + let mut session = SimulationSession::new( + SessionIdentity { + run_id: 42, + source_revision: "native-workflow-interaction-fixture".into(), + }, + grid.clone(), + LiquidTransportConfig { + carrier: SimulationConfig { + density: 2.0, + memory_limit: CAP, + pressure: PressureSettings { + relative_residual: 1e-10, + absolute_residual: 1e-12, + divergence_limit: 1e-8, + max_iterations: 100, + }, + actual_divergence_limit: 1e-5, + max_courant: 0.5, + }, + represented_density: 2.0, + volume_stamp: VolumeStamp { id: 1, version: 0 }, + carrier_id: 2, + }, + PressureImplementation::JacobiPcgV1, + vec![1.0; 8], + CAP, + 3, + ) + .unwrap(); + let mut workspace = + ProviderStepWorkspace::new(&grid, 2.0, CAP, PressureImplementation::JacobiPcgV1).unwrap(); + let mut viscosity = ViscosityWorkspace::new(grid, CAP).unwrap(); + let forces = [BodyForce { + value: [0.125, 0.0, 0.0], + units: ForceUnits::Acceleration, + region: None, + }]; + let mut evidence = None; + let result = session.step_with_configured_pressure_observed( + LiquidStepInputs { + requested_dt: 0.0625, + smoke_source: None, + forces: &forces, + inlet: LiquidInlet::new(VolumeStamp { id: 3, version: 0 }, [[0.0; 2]; 3]).unwrap(), + source: None, + volume: LiquidVolumeSettings { + max_outward_courant: 0.5, + actual_divergence_limit: 1e-5, + }, + }, + &mut workspace, + Some((&mut viscosity, 0.0)), + None, + None, + |_| cancel_before, + 7, + &mut evidence, + ); + assert_eq!(result.is_err(), cancel_before); + (evidence.unwrap(), session) +} + +struct Harness { + context: egui::Context, + view: WorkflowInspector, + time: f64, +} +impl Harness { + fn new() -> Self { + let mut view = WorkflowInspector::default(); + view.set_attempt(fixture()); + view.open(); + Self { + context: egui::Context::default(), + view, + time: 0.0, + } + } + fn frame(&mut self, events: Vec) -> egui::FullOutput { + self.frame_on(events, egui::vec2(1500.0, 1200.0)) + } + fn frame_on(&mut self, events: Vec, size: egui::Vec2) -> egui::FullOutput { + self.time += 0.016; + self.context.run( + egui::RawInput { + time: Some(self.time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + events, + ..Default::default() + }, + |ctx| self.view.show(ctx), + ) + } + fn click(&mut self, id: &str) -> egui::FullOutput { + self.frame(vec![]); + self.frame(vec![]); + let pos = self + .view + .controls + .get(id) + .unwrap_or_else(|| panic!("missing {id}")) + .center(); + self.at(pos) + } + 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 key(&mut self, key: egui::Key) -> egui::FullOutput { + self.frame(vec![egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }]); + self.frame(vec![egui::Event::Key { + key, + physical_key: None, + pressed: false, + repeat: false, + modifiers: Default::default(), + }]); + self.frame(vec![]); + self.frame(vec![]) + } +} +fn contains(output: &egui::FullOutput, text: &str) -> bool { + output.shapes.iter().any( + |shape| matches!(&shape.shape,egui::Shape::Text(t) if t.galley.job.text.contains(text)), + ) +} +#[test] +fn evidence_overflow_scrolls_to_lower_hashes_and_keeps_close_on_screen() { + use crate::configured_worker_tests::{Output, app, finish, launch, package, set}; + use rheon::solver_provider::configured::ContextProviderIdentity; + let output = Output::new(); + let provider = package(false, "bad_once"); + let mut application = app(&output); + let root = output.0.join("evidence-overflow"); + launch(&mut application, set(&provider), &root); + let revision = finish(&mut application); + let member = revision + .members + .iter() + .find(|member| member.member_id == "configured") + .unwrap(); + let inspector = Inspector::default(); + inspector + .reload( + &root.join(&member.attempt.as_ref().unwrap().archive_relative_path), + rheon::recording::RecordingLimits::default(), + ) + .unwrap(); + inspector.select_timestep(2).unwrap(); + let retained = inspector + .workflow_selection() + .unwrap() + .unwrap() + .attempt + .unwrap(); + let mut h = Harness::new(); + let mut attempt = (*retained).clone(); + // Presentation boundary test: lengthen bounded identity text on a real + // configured fallback receipt; numerical and execution facts stay intact. + attempt.pressure.as_mut().unwrap().binding = Some(ContextProviderIdentity { + id: "bounded provider identity ".repeat(35), + version: "1".into(), + manifest_sha256: [41; 32], + checkpoint_sha256: Some([42; 32]), + training_run_id: Some("retained training identity ".repeat(30)), + runtime: "retained runtime ".repeat(50), + source_revision: "retained source ".repeat(50), + }); + attempt.validate().unwrap(); + h.view.set_attempt(Arc::new(attempt)); + h.view.evidence = true; + let size = egui::vec2(1000.0, 600.0); + for _ in 0..3 { + h.frame_on(vec![], size); + } + let close = h.view.controls["close-evidence"]; + assert!( + egui::Rect::from_min_size(egui::Pos2::ZERO, size).contains_rect(close), + "footer must remain reachable on a small native viewport: {close:?}" + ); + let scroll = h.view.controls["evidence-scroll"]; + assert!(scroll.height() <= 440.0); + let visible_rhs = |out: &egui::FullOutput| { + out.shapes.iter().any(|shape| { + matches!(&shape.shape, egui::Shape::Text(text) if text.galley.job.text.contains("Input RHS SHA-256") && shape.clip_rect.intersect(scroll).intersect(egui::Rect::from_min_size(text.pos,text.galley.size())).is_positive()) + }) + }; + assert!( + !visible_rhs(&h.frame_on(vec![], size)), + "lower RHS begins outside the bounded evidence viewport" + ); + h.frame_on( + vec![ + egui::Event::PointerMoved(scroll.center()), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -10000.0), + modifiers: Default::default(), + }, + ], + size, + ); + let mut lower = false; + for _ in 0..5 { + lower |= visible_rhs(&h.frame_on(vec![], size)); + } + assert!( + lower, + "real wheel scrolling must reach lower retained RHS evidence" + ); + let pos = h.view.controls["close-evidence"].center(); + h.frame_on( + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ], + size, + ); + h.frame_on( + vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }], + size, + ); + assert!(!h.view.evidence); + assert!(h.view.open); +} + +#[test] +fn pointer_keyboard_focus_evidence_close_and_reopen_use_real_egui_events() { + let mut h = Harness::new(); + h.frame(vec![]); + h.frame(vec![]); + h.click("node-1"); + assert_eq!(h.view.selected, 1); + let out = h.key(egui::Key::ArrowRight); + assert_eq!(h.view.selected, 2); + assert!(contains(&out, "Requested dt")); + assert!(contains(&out, "6.250000000e-2 s")); + h.key(egui::Key::End); + assert_eq!( + h.view.attempt.as_ref().unwrap().descriptor.nodes()[h.view.selected], + NodeKind::Publication + ); + for _ in 0..4 { + h.key(egui::Key::ArrowLeft); + } + assert_eq!( + h.view.attempt.as_ref().unwrap().descriptor.nodes()[h.view.selected], + NodeKind::NativeFallback + ); + h.key(egui::Key::Home); + assert_eq!(h.view.selected, 0); + h.click("evidence"); + assert!(h.view.evidence); + assert!(contains(&h.frame(vec![]), "Input RHS SHA-256")); + h.click("close"); + assert!(!h.view.open); + h.view.open(); + h.frame(vec![]); + assert!(h.view.open); + assert_eq!(h.view.attempt.as_ref().unwrap().attempt_id, 7); + h.click("node-0"); + h.key(egui::Key::Escape); + assert!(!h.view.open); + assert!(!h.view.evidence); +} +#[test] +fn missing_and_invalid_sources_clear_the_previous_graph_and_no_node_status_is_inferred() { + let mut h = Harness::new(); + assert_eq!( + status( + h.view.attempt.as_ref().unwrap(), + NodeKind::PressureCandidate + ), + "execution unavailable" + ); + for _ in 0..3 { + h.frame(vec![]); + } + let canvas = h.view.controls["canvas"]; + let background = egui::pos2(canvas.right() - 25.0, canvas.top() + 25.0); + h.frame(vec![ + egui::Event::PointerMoved(background), + egui::Event::PointerButton { + pos: background, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ]); + assert!(h.view.pan_drag.is_some()); + h.view.select(None); + assert!( + h.view.pan_drag.is_none(), + "source invalidation drops an in-progress background gesture" + ); + assert!(h.view.attempt.is_none()); + assert!(h.view.source.is_none()); + h.frame(vec![]); + assert!(contains(&h.frame(vec![]), "no exact recorded publication")); + let mut bad = (*fixture()).clone(); + bad.descriptor_sha256[0] ^= 1; + h.view.set_attempt(Arc::new(bad)); + assert!(h.view.attempt.is_none()); + assert!( + h.view + .message + .contains("Invalid workflow execution evidence: workflow descriptor digest") + ); +} +#[test] +fn actual_worker_archive_projection_survives_provider_removal_and_cached_paint_needs_no_source() { + use crate::configured_worker_tests::{Output, app, finish, launch, package, set}; + let output = Output::new(); + let provider = package(false, "bad_once"); + let mut application = app(&output); + let root = output.0.join("workflow"); + launch(&mut application, set(&provider), &root); + let revision = finish(&mut application); + let member = revision + .members + .iter() + .find(|m| m.member_id == "configured") + .unwrap(); + let attempt = member.attempt.as_ref().unwrap(); + let archive = root.join(&attempt.archive_relative_path); + drop(application); + drop(provider); + let inspector = Inspector::default(); + inspector + .reload(&archive, rheon::recording::RecordingLimits::default()) + .unwrap(); + inspector.select_timestep(2).unwrap(); + let mut h = Harness::new(); + h.view.select(Some(&inspector)); + let retained = h + .view + .attempt + .clone() + .expect("actual accepted workflow receipt"); + assert_eq!(retained.publication, Some(2)); + assert!(retained.pressure.as_ref().unwrap().rejection.is_some()); + assert!( + retained + .nodes + .iter() + .any(|n| n.node == NodeKind::NativeFallback && n.status == NodeStatus::Selected) + ); + drop(inspector); + std::fs::rename(&archive, archive.with_extension("retained-outside-source")).unwrap(); + h.frame(vec![]); + let out = h.frame(vec![]); + assert!(contains(&out, "Actual pressure provider:")); + assert_eq!( + h.view.attempt.as_ref().unwrap().descriptor_sha256, + retained.descriptor_sha256 + ); +} + +/// This prepares retained inputs only. Native qualification still operates the +/// ordinary application through its real display, pointer and keyboard paths. +#[test] +#[ignore = "explicit native smoke fixture preparation; requires exclusive temporary root"] +fn prepare_native_workflow_fixture() { + use crate::configured_worker_tests::{Output, app, finish, launch, package, set}; + use sha2::{Digest, Sha256}; + let root = std::path::PathBuf::from( + std::env::var_os("RHEON_WORKFLOW_NATIVE_ROOT").expect("explicit temporary output root"), + ); + assert!(root.starts_with(std::env::temp_dir())); + std::fs::create_dir(&root).unwrap(); + let mut provider = package(false, "bad_once"); + let retained_provider = root.join("provider"); + std::fs::rename(&provider.root, &retained_provider).unwrap(); + provider.root = retained_provider; + provider.manifest = provider.root.join("adapter.json"); + let code_path = provider.root.join("adapter.py"); + let code = std::fs::read_to_string(&code_path).unwrap().replace( + " result = solve(a, rhs)", + " if (root / 'slow').exists(): time.sleep(0.4)\n result = solve(a, rhs)", + ); + std::fs::write(code_path, &code).unwrap(); + let mut manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&provider.manifest).unwrap()).unwrap(); + manifest["files"]["adapter.py"]["bytes"] = serde_json::json!(code.len()); + manifest["files"]["adapter.py"]["sha256"] = + serde_json::json!(format!("{:x}", Sha256::digest(code.as_bytes()))); + std::fs::write(&provider.manifest, serde_json::to_vec(&manifest).unwrap()).unwrap(); + let mut revision = set(&provider); + for member in &mut revision.members { + member.spec.constructor.max_steps = 64; + member.spec.future.requested_steps = 64; + member.spec.future.recording.max_records = 65; + member.spec = crate::run_sets::configured::FrozenConfiguredSpec::new( + member.spec.constructor.clone(), + member.spec.future.clone(), + ) + .unwrap(); + } + revision.validate().unwrap(); + let output = Output::new(); + let mut application = app(&output); + launch(&mut application, revision, &root.join("set")); + let completed = finish(&mut application); + std::fs::write(root.join("manifest-path.txt"), &application.run_sets.path).unwrap(); + assert_eq!(completed.members.len(), 2); + drop(application); + std::fs::remove_file(provider.root.join("first-request-observed")).unwrap(); + std::fs::write( + provider.root.join("slow"), + b"bounded native interaction delay", + ) + .unwrap(); + // The external native procedure owns this retained disposable root and its + // eventual cleanup; the provider must remain available for the live rerun. + std::mem::forget(provider); + println!("NATIVE_WORKFLOW_FIXTURE_READY {}", root.display()); +} + +#[test] +fn interval_replacement_preserves_node_focus_pan_zoom_and_text_loop_equivalence() { + let mut h = Harness::new(); + h.frame(vec![]); + h.frame(vec![]); + h.click("node-2"); + h.view.zoom = 1.1; + h.view.pan = egui::vec2(-30.0, -30.0); + let focused = h.context.memory(|memory| memory.focused()); + let mut next = (*fixture()).clone(); + next.attempt_id = 8; + next.accepted_generation = 1; + next.attempted_generation = 2; + next.publication = Some(2); + next.interval_start = 0.0625; + h.view.set_attempt(Arc::new(next)); + h.frame(vec![]); + assert_eq!(h.view.selected, 2); + assert_eq!(h.view.zoom, 1.1); + assert_eq!(h.view.pan, egui::vec2(-30.0, -30.0)); + assert_eq!(h.context.memory(|memory| memory.focused()), focused); + h.key(egui::Key::ArrowRight); + assert_eq!(h.view.selected, 3); + h.view.pan = egui::vec2(-30.0, -280.0); + let output = h.frame(vec![]); + assert!(contains(&output, "pressure iteration loop")); + h.click("reset"); + assert_eq!(h.view.zoom, 0.85); + assert_eq!(h.view.pan, egui::Vec2::ZERO); + h.click("node-2"); + h.key(egui::Key::ArrowRight); + assert_eq!(h.view.selected, 3); +} + +#[test] +fn existing_carrier_and_material_archives_remain_selectable_with_workflow_unavailable() { + use rheon::*; + let root = crate::configured_worker_tests::Output::new(); + let grid = GridGeometry::new([4; 3], [0.25; 3], [0.0; 3]).unwrap(); + let carrier = Simulation::new( + grid.clone(), + SimulationConfig { + density: 2.0, + memory_limit: 4 * 1024 * 1024, + pressure: PressureSettings { + relative_residual: 1e-10, + absolute_residual: 1e-12, + divergence_limit: 1e-8, + max_iterations: 100, + }, + actual_divergence_limit: 1e-5, + max_courant: 0.5, + }, + ) + .unwrap(); + let material = ParticleFluidSimulation::new( + grid, + ParticleFluidConfig { + fill: DomainFill::EmittedFluid, + samples_per_axis: 4, + max_particles: 64, + ..Default::default() + }, + ) + .unwrap(); + for (name, is_material) in [("carrier", false), ("material", true)] { + let inspector = Inspector::default(); + let path = root.0.join(format!("{name}.sqlite")); + let writer = if is_material { + inspector + .begin( + &material, + &path, + rheon::recording::RecordingLimits::default(), + ) + .unwrap() + } else { + inspector + .begin_carrier( + &carrier, + &path, + rheon::recording::RecordingLimits::default(), + ) + .unwrap() + }; + drop(writer); + inspector.select_timestep(0).unwrap(); + let mut view = WorkflowInspector::default(); + view.select(Some(&inspector)); + assert!(view.attempt.is_none()); + assert!(view.source.is_some()); + assert!(view.message.contains("legacy archive")); + } +} + +#[test] +#[ignore = "release-only frozen G1 capture plus cached native projection protocol"] +fn paired_capture_and_cached_projection_overhead_protocol() { + use rheon::*; + use std::time::Instant; + assert!( + !std::hint::black_box(cfg!(debug_assertions)), + "run optimized release protocol" + ); + const CAP: usize = 16 * 1024 * 1024; + let make = |run_id| { + let grid = GridGeometry::new([16; 3], [1.0 / 16.0; 3], [0.0; 3]).unwrap(); + let session = SimulationSession::new( + SessionIdentity { + run_id, + source_revision: "g1-fixed-overhead-fixture".into(), + }, + grid.clone(), + LiquidTransportConfig { + carrier: SimulationConfig { + density: 998.2, + memory_limit: CAP, + pressure: 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, + }, + represented_density: 998.2, + volume_stamp: VolumeStamp { id: 1, version: 0 }, + carrier_id: 2, + }, + PressureImplementation::JacobiPcgV1, + vec![0.5; 16usize.pow(3)], + CAP, + 1000, + ) + .unwrap(); + let workspace = + ProviderStepWorkspace::new(&grid, 998.2, CAP, PressureImplementation::JacobiPcgV1) + .unwrap(); + (session, workspace) + }; + let (mut baseline, mut bw) = make(100); + let (mut observed, mut ow) = make(101); + let force = [BodyForce { + value: [0.001, 0.0, 0.0], + units: ForceUnits::Acceleration, + region: None, + }]; + let inputs = || LiquidStepInputs { + requested_dt: 0.001, + smoke_source: None, + forces: &force, + source: None, + inlet: LiquidInlet::new(VolumeStamp { id: 3, version: 0 }, [[0.0; 2]; 3]).unwrap(), + volume: LiquidVolumeSettings { + max_outward_courant: 1.0, + actual_divergence_limit: 1e-5, + }, + }; + let state_bits = |session: &SimulationSession| { + let snapshot = session.snapshot(); + let state = snapshot.state(); + let mut bits = vec![ + state.carrier.generation, + state.carrier.time.to_bits(), + state.liquid.time.to_bits(), + state.liquid.density.to_bits(), + state.carrier_stamp.id, + state.carrier_stamp.version, + state.liquid.stamp.id, + state.liquid.stamp.version, + ]; + for field in [ + state.carrier.x, + state.carrier.y, + state.carrier.z, + state.carrier.tracer, + ] { + bits.extend(field.iter().map(|v| v.to_bits() as u64)); + } + for field in [state.pressure, state.liquid.fraction] { + bits.extend(field.iter().map(|v| v.to_bits())); + } + bits + }; + let mut view = WorkflowInspector::default(); + let mut pairs = Vec::new(); + for interval in 0..35u64 { + let mut ns = [0u128; 2]; + for mode in if interval % 2 == 0 { [0, 1] } else { [1, 0] } { + let start = Instant::now(); + if mode == 0 { + baseline + .step_with_pressure_provider(inputs(), &mut bw, None, None, |_| false) + .unwrap(); + } else { + let mut evidence = None; + observed + .step_with_configured_pressure_observed( + inputs(), + &mut ow, + None, + None, + None, + |_| false, + interval + 1, + &mut evidence, + ) + .unwrap(); + view.set_attempt(evidence.unwrap()); + assert!(view.attempt.is_some()); + } + ns[mode] = start.elapsed().as_nanos(); + } + assert_eq!(state_bits(&baseline), state_bits(&observed)); + if interval >= 5 { + pairs.push(ns); + } + } + let mut ratios: Vec<_> = pairs + .iter() + .map(|pair| pair[1] as f64 / pair[0] as f64 - 1.0) + .collect(); + ratios.sort_by(f64::total_cmp); + let median = (ratios[14] + ratios[15]) * 0.5; + println!("G1_CAPTURE_AND_CACHED_PROJECTION_PAIRED_NS={pairs:?}"); + println!("G1_MEDIAN_CAPTURE_AND_CACHED_PROJECTION_OVERHEAD={median:.8}"); + println!( + "SCOPE=actual capture plus cached native WorkflowInspector::set_attempt; excludes disk I/O and rasterization" + ); + assert!( + median <= 0.05, + "fixed <=5% capture+projection gate failed; retain paired evidence and investigate" + ); +} + +#[test] +fn ordered_text_focus_uses_stable_node_ids_and_canvas_controls_accept_pointer_events() { + let mut h = Harness::new(); + h.frame(vec![]); + h.frame(vec![]); + let zoom = h.view.controls["zoom"]; + h.at(egui::pos2(zoom.left() + 25.0, zoom.center().y)); + assert_ne!(h.view.zoom, 0.85); + let rect = h.view.controls["canvas"]; + let start = rect.left_bottom() + egui::vec2(18.0, -18.0); + let end = start + egui::vec2(25.0, -70.0); + h.frame(vec![ + egui::Event::PointerMoved(start), + egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ]); + h.frame(vec![egui::Event::PointerMoved(end)]); + h.frame(vec![egui::Event::PointerButton { + pos: end, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + assert_ne!(h.view.pan, egui::Vec2::ZERO); + h.click("reset"); + assert_eq!(h.view.pan, egui::Vec2::ZERO); + assert_eq!(h.view.zoom, 0.85); + // The same restoration transition used by keyboard navigation scrolls the + // complete text list into view before actual pointer activation. + h.view.focus_text = true; + h.view.restore_focus = true; + h.view.selected = 2; + h.frame(vec![]); + h.frame(vec![]); + h.frame(vec![]); + h.click("text-2"); + assert!(h.view.focus_text); + h.key(egui::Key::ArrowRight); + assert_eq!(h.view.selected, 3); + h.frame(vec![]); + h.key(egui::Key::End); + assert_eq!( + h.view.attempt.as_ref().unwrap().descriptor.nodes()[h.view.selected], + NodeKind::Publication + ); + assert!(h.view.focus_text); +} + +#[test] +fn verified_comparison_sides_load_exact_workflows_and_pair_or_source_changes_clear_them() { + use crate::{ + comparison_archive::{SavedRecipe, Side}, + configured_worker_tests::{Output, app, finish, launch, package, set}, + run_sets::source::SavedRunSet, + saved_comparison::*, + }; + use rheon::recording::comparison::{FieldRole, PressurePolicy, Recipe}; + let output = Output::new(); + let provider = package(false, "bad_once"); + let mut application = app(&output); + let root = output.0.join("comparison-workflow"); + launch(&mut application, set(&provider), &root); + let revision = finish(&mut application); + drop(application); + drop(provider); + let recipe = SavedRecipe::capture_source( + &root, + SavedRunSet::Configured(revision.clone()), + "native", + "configured", + &[2, 3], + &[2, 3], + Recipe { + field: FieldRole::IntervalPressure, + time_tolerance: 0.0, + pressure: PressurePolicy::Canonical, + }, + ) + .unwrap(); + recipe.save_new(&root.join("comparison.json")).unwrap(); + let mut page = SavedComparisonPage::default(); + page.set_location(root.to_string_lossy(), "ignored-manifest.json"); + page.open_saved_recipe(); + page.compute(); + assert_eq!( + page.presentation(), + Presentation::Verified, + "{}", + page.message() + ); + let mut view = WorkflowInspector::default(); + let mut run_ids = Vec::new(); + for side in [Side::Reference, Side::Candidate] { + view.terminal_request = true; + view.load_comparison(page.load_selected_workflow(side)); + assert!(!view.terminal_request); + assert!(view.comparison_context); + let key = page.workflow_key(side).unwrap(); + view.validate_comparison(Some(&key)); + let attempt = view.attempt.as_ref().unwrap(); + assert_eq!(attempt.publication, Some(2)); + run_ids.push(attempt.run_id); + } + assert_ne!(run_ids[0], run_ids[1]); + let ctx = egui::Context::default(); + let mut frame = |events| { + ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1800.0, 2600.0), + )), + events, + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| page.show(ui)); + }, + ) + }; + frame(vec![]); + frame(vec![]); + let rect = page.control_rect("matched-pair").unwrap(); + let pos = egui::pos2(rect.left() + 95.0, rect.center().y); + let mut frame = |events| { + ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1800.0, 2600.0), + )), + events, + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| page.show(ui)); + }, + ) + }; + frame(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ]); + frame(vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + frame(vec![egui::Event::Key { + key: egui::Key::ArrowRight, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }]); + assert_eq!(page.selected_pair().unwrap().pair.candidate.ordinal, 3); + let key = page.workflow_key(Side::Candidate); + view.validate_comparison(key.as_ref()); + assert!(view.attempt.is_none()); + assert!(!view.evidence); + view.load_comparison(page.load_selected_workflow(Side::Candidate)); + assert_eq!(view.attempt.as_ref().unwrap().publication, Some(3)); + page.set_location(root.join("new-namespace").to_string_lossy(), "missing.json"); + view.validate_comparison(page.workflow_key(Side::Candidate).as_ref()); + assert!(view.attempt.is_none()); + page.set_location(root.to_string_lossy(), "ignored.json"); + page.open_saved_recipe(); + page.compute(); + let archive = root.join( + &revision.members[1] + .attempt + .as_ref() + .unwrap() + .archive_relative_path, + ); + std::fs::rename(&archive, archive.with_extension("unavailable")).unwrap(); + view.load_comparison(page.load_selected_workflow(Side::Candidate)); + assert!(view.attempt.is_none()); + assert!(view.message.contains("Invalid or unavailable")); +} + +#[test] +fn generation_zero_cancelled_terminal_loads_via_native_action_without_an_accepted_row() { + use rheon::recording::{ + RecordingLimits, StepRecorder, + provider::{configured_run, runtime::RuntimeTerminationCause}, + workflow::WorkflowTermination, + }; + let output = crate::configured_worker_tests::Output::new(); + let path = output.0.join("zero.sqlite"); + let (attempt, session) = observed_fixture(true); + let run = configured_run( + &session.snapshot(), + "actual generation-zero cancelled workflow fixture", + ) + .unwrap(); + let mut writer = StepRecorder::create(&path, run, RecordingLimits::default()).unwrap(); + WorkflowTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::Cancelled, + "actual cancellation before numerical publication", + None, + Some(&attempt), + ) + .unwrap() + .finish(&mut writer) + .unwrap(); + assert_eq!(writer.recorded_count(), 0); + drop(writer); + drop(session); + let inspector = Inspector::default(); + inspector.reload(&path, RecordingLimits::default()).unwrap(); + let mut h = Harness::new(); + h.view.select(Some(&inspector)); + h.frame(vec![]); + h.frame(vec![]); + h.click("terminal"); + assert!(h.view.terminal_request); + h.view.select(Some(&inspector)); + let selected = h.view.attempt.as_ref().unwrap(); + assert_eq!(selected.accepted_generation, 0); + assert_eq!(selected.attempted_generation, 1); + assert!(selected.publication.is_none()); + assert_eq!(selected.outcome, rheon::workflow::AttemptOutcome::Cancelled); + h.frame(vec![]); + assert!(contains(&h.frame(vec![]), "Cancelled")); + h.view.select(Some(&inspector)); + assert!(h.view.terminal_mode); + assert!(h.view.attempt.is_some()); +} + +#[test] +fn cold_sized_end_and_coalesced_pointer_key_keep_selection_after_idle_frames() { + let size = egui::vec2(1600.0, 1000.0); + let render = |h: &mut Harness, events| { + h.time += 0.016; + h.context.run( + egui::RawInput { + time: Some(h.time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + events, + ..Default::default() + }, + |ctx| { + egui::TopBottomPanel::top("archive-navigation-fixture") + .exact_height(134.0) + .show(ctx, |ui| { + ui.label("Exact recorded ordinal 2"); + }); + h.view.show(ctx); + }, + ) + }; + for coalesced in [false, true] { + let mut h = Harness::new(); + for _ in 0..5 { + render(&mut h, vec![]); + } + render( + &mut h, + vec![egui::Event::PointerMoved(egui::pos2(87.0, 990.0))], + ); + let pan_before = h.view.pan; + let pos = h.view.controls["node-1"].center(); + render( + &mut h, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ], + ); + assert_eq!( + h.view.pan, pan_before, + "node pointer press must not start background pan" + ); + let release = egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }; + let end = egui::Event::Key { + key: egui::Key::End, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }; + if coalesced { + render(&mut h, vec![release, end]); + } else { + render(&mut h, vec![release]); + for _ in 0..2 { + render(&mut h, vec![]); + } + assert_eq!(h.view.selected, 1); + render(&mut h, vec![end]); + } + render( + &mut h, + vec![egui::Event::Key { + key: egui::Key::End, + physical_key: None, + pressed: false, + repeat: false, + modifiers: Default::default(), + }], + ); + for _ in 0..6 { + render(&mut h, vec![]); + } + assert_eq!( + h.view.attempt.as_ref().unwrap().descriptor.nodes()[h.view.selected], + NodeKind::Publication, + "coalesced={coalesced}" + ); + for _ in 0..4 { + for pressed in [true, false] { + render( + &mut h, + vec![egui::Event::Key { + key: egui::Key::ArrowLeft, + physical_key: None, + pressed, + repeat: false, + modifiers: Default::default(), + }], + ); + } + for _ in 0..3 { + render(&mut h, vec![]); + } + } + assert_eq!( + h.view.attempt.as_ref().unwrap().descriptor.nodes()[h.view.selected], + NodeKind::NativeFallback, + "coalesced={coalesced}" + ); + } +} + +#[test] +fn only_background_press_owns_pan_and_prepress_motion_is_excluded() { + let mut h = Harness::new(); + for _ in 0..4 { + h.frame(vec![]); + } + let button = |pos, pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + let outside = egui::pos2(5.0, 1190.0); + h.frame(vec![egui::Event::PointerMoved(outside)]); + let node = h.view.controls["node-1"].center(); + let before = h.view.pan; + h.frame(vec![egui::Event::PointerMoved(node), button(node, true)]); + assert_eq!( + h.view.pan, before, + "moving onto a node before its press is not a background gesture" + ); + let moved = node + egui::vec2(40.0, 30.0); + h.frame(vec![egui::Event::PointerMoved(moved)]); + assert_eq!( + h.view.pan, before, + "a node-origin held motion cannot steal background panning" + ); + h.frame(vec![button(moved, false)]); + let canvas = h.view.controls["canvas"]; + let background = egui::pos2(canvas.right() - 25.0, canvas.top() + 25.0); + h.frame(vec![egui::Event::PointerMoved(outside)]); + h.frame(vec![ + egui::Event::PointerMoved(background), + button(background, true), + ]); + assert_eq!( + h.view.pan, before, + "background press ignores preceding outside-pointer relocation" + ); + let delta = egui::vec2(-40.0, 30.0); + h.frame(vec![egui::Event::PointerMoved(background + delta)]); + assert_eq!( + h.view.pan, + before + delta, + "held background movement still pans by its actual displacement" + ); + h.frame(vec![button(background + delta, false)]); + h.frame(vec![egui::Event::PointerMoved(outside)]); + assert_eq!( + h.view.pan, + before + delta, + "released drag has no continuing ownership" + ); + h.view.open(); + assert!(h.view.pan_drag.is_none()); + assert_eq!( + h.view.pan, + before + delta, + "reopening preserves the view without a stale drag" + ); +} diff --git a/src/bin/rheon-desktop.rs b/src/bin/rheon-desktop.rs index d747e112..6b3209c8 100644 --- a/src/bin/rheon-desktop.rs +++ b/src/bin/rheon-desktop.rs @@ -58,6 +58,8 @@ mod settings; mod transport_view; #[path = "desktop/viewport.rs"] mod viewport; +#[path = "desktop/workflow_inspector.rs"] +mod workflow_inspector; use eframe::egui; use history::{LiveHistory, PreviewFrame}; use rheon::{ @@ -519,7 +521,7 @@ fn run_simulations( .begin_carrier(sim.native()?, &path, request.recording_limits) .map_err(|e| format!("Carrier recording start at {} failed: {e}", path.display()))? }; - use rheon::recording::provider::runtime::{RuntimeTermination, RuntimeTerminationCause}; + use rheon::recording::provider::runtime::RuntimeTerminationCause; let mut termination_cause = RuntimeTerminationCause::StorageFailure; let result: Result<(), String> = (|| { if let Some(configured) = sim.configured() { @@ -670,11 +672,12 @@ fn run_simulations( ), }; let terminal = if let Some(configured) = sim.configured() { - RuntimeTermination::capture( + rheon::recording::workflow::WorkflowTermination::capture( &configured.snapshot(), termination_cause, &accepted_session::recording_detail(&detail), configured.runtime_observation(), + configured.workflow_attempt(), ) .and_then(|termination| termination.finish(&mut recorder)) } else { @@ -1015,6 +1018,7 @@ struct App { configured_controls: configured_controls::ConfiguredControls, saved_comparison: saved_comparison::SavedComparisonPage, saved_comparison_active: bool, + workflow: workflow_inspector::WorkflowInspector, comparison_fields: comparison_field_view::ComparisonFieldView, collision: collision_ui::CollisionControls, selected: PressureImplementation, @@ -1066,6 +1070,7 @@ impl Default for App { configured_controls: Default::default(), saved_comparison: saved_comparison::SavedComparisonPage::default(), saved_comparison_active: false, + workflow: Default::default(), comparison_fields: Default::default(), collision: collision_ui::CollisionControls::default(), selected: PressureImplementation::default(), @@ -1132,6 +1137,10 @@ impl App { }, ); self.run_sets.controls(ui, self.worker.is_none()); + let workflow = ui.button("Inspect executed workflow"); + if workflow.clicked() { + self.workflow.open_from(workflow.id); + } if ui .add_enabled( self.worker.is_none(), @@ -1191,6 +1200,25 @@ impl App { self.track = 0; self.follow_live = true; } + fn workflow_window(&mut self, ctx: &egui::Context) { + let inspector = if self.archive.active { + Some(&self.archive.inspector) + } else { + self.history.as_ref().and_then(|history| { + if self + .running_request + .as_ref() + .is_some_and(|r| r.material.is_none()) + { + history.carrier_inspectors.get(self.track) + } else { + Some(&history.inspector) + } + }) + }; + self.workflow.select(inspector); + self.workflow.show(ctx); + } fn refresh_threads(&mut self, available: usize) { self.thread_limit = available.max(1); self.threads = if self.automatic_threads { @@ -1927,10 +1955,31 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } if self.saved_comparison_active { + let mut workflow_action = None; egui::TopBottomPanel::top("comparison-navigation").show(ctx, |ui| { if ui.button("Back to simulation").clicked() { self.saved_comparison_active = false; } + ui.horizontal(|ui| { + for (side, label) in [ + ( + comparison_archive::Side::Reference, + "Inspect reference workflow", + ), + ( + comparison_archive::Side::Candidate, + "Inspect candidate workflow", + ), + ] { + let response = ui.add_enabled( + self.saved_comparison.selected_pair().is_some(), + egui::Button::new(label), + ); + if response.clicked() { + workflow_action = Some((side, response.id)); + } + } + }); }); egui::CentralPanel::default().show(ctx, |ui| { egui::ScrollArea::vertical() @@ -1941,6 +1990,17 @@ impl eframe::App for App { .show(ui, self.saved_comparison.selected_pair()); }); }); + if let Some((side, trigger)) = workflow_action { + let loaded = self.saved_comparison.load_selected_workflow(side); + self.workflow.load_comparison(loaded); + self.workflow.open_from(trigger); + } + let key = self + .workflow + .comparison_side() + .and_then(|side| self.saved_comparison.workflow_key(side)); + self.workflow.validate_comparison(key.as_ref()); + self.workflow.show(ctx); return; } if self.run_sets.active { @@ -1972,7 +2032,14 @@ impl eframe::App for App { return; } if self.archive.active { + egui::TopBottomPanel::bottom("archive-workflow-action").show(ctx, |ui| { + let workflow = ui.button("Inspect executed workflow"); + if workflow.clicked() { + self.workflow.open_from(workflow.id); + } + }); self.archive.show(ctx); + self.workflow_window(ctx); return; } if self.playing { @@ -2146,6 +2213,7 @@ impl eframe::App for App { self.history.as_ref().map(|h| &h.inspector), ) }); + self.workflow_window(ctx); if self.worker.is_some() || self.playing { ctx.request_repaint_after(Duration::from_millis(16)); } diff --git a/src/lib.rs b/src/lib.rs index 7a600376..c373b250 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,7 @@ pub use tracer_barrier::{ VisibleTracerSample, advect_tracer_with_barrier, }; mod simulation; +pub mod workflow; pub use simulation::{ AcceptedIntervalPressure, CarrierStepControls, Simulation, SimulationConfig, SimulationError, SmokeSource, StateView, StepReport, StepStage, diff --git a/src/liquid_step.rs b/src/liquid_step.rs index 3c029972..a6974623 100644 --- a/src/liquid_step.rs +++ b/src/liquid_step.rs @@ -559,6 +559,12 @@ impl LiquidTransportSimulation { provider.expect("provider path qualified before commit"), )) } + pub(crate) fn workflow_descriptor( + &self, + viscosity: Option, + ) -> crate::workflow::WorkflowDescriptor { + self.carrier.workflow_descriptor(viscosity) + } pub(crate) fn step_with_configured_pressure( &mut self, inputs: LiquidStepInputs<'_>, @@ -664,6 +670,9 @@ impl LiquidTransportSimulation { .and_then(|n| n.checked_add(viscosity_bytes)) .and_then(|n| n.checked_add(provider_bytes)) .ok_or(LiquidStepError::AllocationFailed)?; + let workflow = configured + .as_ref() + .map(|configured| configured.workflow.clone()); let mut context_adapter = configured.map(|configured| { ContextAdapter::new(configured, &self.pressure, self.carrier.grid().counts()) }); @@ -698,11 +707,13 @@ impl LiquidTransportSimulation { preferred, } }), + workflow.as_deref(), |stage| cancel(LiquidStepStage::Carrier(stage)), )?; // Capture only bounded metadata before final publication. No prior // pressure copy is added: the adapter borrows this owner's accepted array. - let configured_report = context_adapter.map(|adapter| ConfiguredProviderReport { + let mut configured_report = context_adapter.map(|adapter| ConfiguredProviderReport { + workflow: None, dynamic_viscosity: candidate.viscosity.map_or(0.0, |v| v.dynamic_viscosity), binding: adapter.binding, context: adapter.stamp.get(), @@ -757,9 +768,17 @@ impl LiquidTransportSimulation { } else { None }; + crate::workflow::complete( + workflow.as_deref(), + crate::workflow::NodeKind::VolumeTransport, + crate::workflow::NodeStatus::Completed, + ); if cancel(LiquidStepStage::BeforeCommit) { return Err(LiquidStepError::Cancelled); } + if let (Some(trace), Some(report)) = (workflow.as_deref(), configured_report.as_mut()) { + report.workflow = Some(crate::workflow::accepted(trace)); + } // All sizes, times, versions and numeric gates have already passed. // Pressure copy and owner swaps are infallible and invoke no callbacks. let pressure = match &boundary { diff --git a/src/liquid_volume.rs b/src/liquid_volume.rs index 1d8cfe9a..71ac2957 100644 --- a/src/liquid_volume.rs +++ b/src/liquid_volume.rs @@ -185,6 +185,9 @@ pub struct LiquidVolumeSource<'a> { rate: &'a [f64], } impl<'a> LiquidVolumeSource<'a> { + pub(crate) fn workflow_parts(self) -> (VolumeStamp, &'a [f64]) { + (self.stamp, self.rate) + } pub fn new(stamp: VolumeStamp, rate: &'a [f64]) -> Result { if rate.iter().any(|v| !v.is_finite()) { return Err(LiquidVolumeError::InvalidSource); diff --git a/src/provider_step.rs b/src/provider_step.rs index cb67d477..1fed58a8 100644 --- a/src/provider_step.rs +++ b/src/provider_step.rs @@ -83,6 +83,7 @@ impl ProviderStepWorkspace { pub(crate) fn pressure(&self) -> &[f64] { self.provider.pressure() } + #[allow(clippy::too_many_arguments)] // Existing explicit numerical inputs plus optional internal observation. pub(crate) fn solve( &mut self, operator: &PressureOperator<'_>, @@ -90,10 +91,12 @@ impl ProviderStepWorkspace { dt: f64, settings: PressureSettings, preferred: Option<&mut dyn PressureProvider>, + workflow: Option<&crate::workflow::Capture>, cancel: impl FnMut() -> bool, ) -> Result { operator.build_rhs(velocity, dt, &mut self.rhs)?; - Ok(self.provider.solve( + crate::workflow::pressure_requested(workflow, &self.rhs); + Ok(self.provider.solve_observed( PressureRequest { grid: &self.grid, density: self.density, @@ -103,6 +106,7 @@ impl ProviderStepWorkspace { settings, preferred, cancel, + workflow, )?) } } diff --git a/src/recording.rs b/src/recording.rs index ad00278e..12f46854 100644 --- a/src/recording.rs +++ b/src/recording.rs @@ -6,6 +6,7 @@ pub mod comparison; pub mod diagnostic; mod material; pub mod provider; +pub mod workflow; use crate::{GridGeometry, SessionIdentity, SnapshotStage}; pub use carrier::{RecordedCarrier, carrier_publication, carrier_run}; pub use material::{ diff --git a/src/recording/provider.rs b/src/recording/provider.rs index 50afc797..7486b241 100644 --- a/src/recording/provider.rs +++ b/src/recording/provider.rs @@ -356,9 +356,13 @@ fn decode_controls( run: &RecordingRun, step: &RecordedStep, ) -> Result { - let has_runtime = step.controls.starts_with(RUNTIME_RECEIPT); + let workflow = super::workflow::split_publication_controls(&step.controls)?; + let controls = workflow + .as_ref() + .map_or(step.controls.as_str(), |(controls, _, _)| controls.as_str()); + let has_runtime = controls.starts_with(RUNTIME_RECEIPT); let mut d = Decoder::text( - &step.controls, + controls, if has_runtime { RUNTIME_RECEIPT } else { @@ -397,6 +401,12 @@ fn decode_controls( ))?, )?; } + if let Some((_, attempt, source)) = &workflow { + if *source != source_binding(run) { + return Err(RecordingError::Invalid("workflow publication source")); + } + super::workflow::validate_publication(run, step, attempt, receipt.as_ref())?; + } Ok(PublicationControls { carrier_stamp, fraction_stamp, @@ -595,10 +605,25 @@ pub fn verify_configured_recording( // Only a digest and lineage survive each iteration, not a second field set. let mut previous: Option<(u64, f64, [u8; 32])> = None; let mut previous_time: Option = None; + let mut previous_native: Option<[u8; 32]> = None; + let mut previous_attempt_id: Option = None; let mut history: Option<(f64, usize, bool)> = None; for ordinal in 0..reader.count()? { let step = reader.select(ordinal)?; let current = RecordedConfiguredSession::decode(reader.run(), &step)?; + if let Some((_, attempt, _)) = super::workflow::split_publication_controls(&step.controls)? + { + if previous_native.is_some_and(|digest| digest != attempt.input_sha256) { + return Err(RecordingError::Invalid("workflow prior native input")); + } + if previous_attempt_id.is_some_and(|prior| attempt.attempt_id <= prior) { + return Err(RecordingError::Invalid( + "workflow attempt identity sequence", + )); + } + previous_attempt_id = Some(attempt.attempt_id); + } + previous_native = Some(super::workflow::native_state_digest(¤t)?); if let Some((start, _)) = ¤t.interval_pressure && previous_time.is_some_and(|time| time.to_bits() != start.to_bits()) { @@ -782,7 +807,7 @@ fn validate_pressure(p: PressureReport, accepted: bool) -> Result<(), RecordingE } // Bind each publication to its actual Session identity and immutable source // configuration/geometry. Caller provenance is intentionally not an identity. -fn source_binding(run: &RecordingRun) -> [u8; 32] { +pub(super) fn source_binding(run: &RecordingRun) -> [u8; 32] { let mut hash = Sha256::new(); hash.update(run.identity.run_id.to_le_bytes()); hash_text(&mut hash, &run.identity.source_revision); @@ -1198,33 +1223,33 @@ fn get_pressure(d: &mut Decoder) -> Result { } #[derive(Default)] -struct Encoder(Vec); +pub(super) struct Encoder(pub(super) Vec); impl Encoder { - fn u8(&mut self, v: u8) { + pub(super) fn u8(&mut self, v: u8) { self.0.push(v); } - fn boolean(&mut self, v: bool) { + pub(super) fn boolean(&mut self, v: bool) { self.u8(u8::from(v)); } - fn u32(&mut self, v: u32) { + pub(super) fn u32(&mut self, v: u32) { self.0.extend_from_slice(&v.to_le_bytes()); } - fn u64(&mut self, v: u64) { + pub(super) fn u64(&mut self, v: u64) { self.0.extend_from_slice(&v.to_le_bytes()); } - fn usize(&mut self, v: usize) { + pub(super) fn usize(&mut self, v: usize) { self.u64(v as u64); } - fn f64(&mut self, v: f64) { + pub(super) fn f64(&mut self, v: f64) { self.u64(v.to_bits()); } - fn string(&mut self, v: &str) -> Result<(), RecordingError> { + pub(super) fn string(&mut self, v: &str) -> Result<(), RecordingError> { bounded(v)?; self.u32(v.len() as u32); self.0.extend_from_slice(v.as_bytes()); Ok(()) } - fn text(self, prefix: &str) -> Result { + pub(super) fn text(self, prefix: &str) -> Result { if self.0.len() > (MAX_TEXT - prefix.len()) / 2 { return Err(RecordingError::Limit("configured receipt text")); } @@ -1237,12 +1262,12 @@ impl Encoder { Ok(out) } } -struct Decoder { - data: Vec, +pub(super) struct Decoder { + pub(super) data: Vec, at: usize, } impl Decoder { - fn text(value: &str, prefix: &str) -> Result { + pub(super) fn text(value: &str, prefix: &str) -> Result { if value.len() > MAX_TEXT { return Err(RecordingError::Limit("configured metadata")); } @@ -1268,7 +1293,7 @@ impl Decoder { } Ok(Self { data, at: 0 }) } - fn array(&mut self) -> Result<[u8; N], RecordingError> { + pub(super) fn array(&mut self) -> Result<[u8; N], RecordingError> { let end = self .at .checked_add(N) @@ -1282,29 +1307,29 @@ impl Decoder { self.at = end; Ok(value) } - fn u8(&mut self) -> Result { + pub(super) fn u8(&mut self) -> Result { Ok(self.array::<1>()?[0]) } - fn boolean(&mut self) -> Result { + pub(super) fn boolean(&mut self) -> Result { match self.u8()? { 0 => Ok(false), 1 => Ok(true), _ => Err(RecordingError::Invalid("configured boolean")), } } - fn u32(&mut self) -> Result { + pub(super) fn u32(&mut self) -> Result { Ok(u32::from_le_bytes(self.array()?)) } - fn u64(&mut self) -> Result { + pub(super) fn u64(&mut self) -> Result { Ok(u64::from_le_bytes(self.array()?)) } - fn usize(&mut self) -> Result { + pub(super) fn usize(&mut self) -> Result { usize::try_from(self.u64()?).map_err(|_| RecordingError::Limit("configured integer")) } - fn f64(&mut self) -> Result { + pub(super) fn f64(&mut self) -> Result { Ok(f64::from_bits(self.u64()?)) } - fn string(&mut self) -> Result { + pub(super) fn string(&mut self) -> Result { let len = self.u32()? as usize; if len > 1000 { return Err(RecordingError::Invalid("configured text")); @@ -1323,7 +1348,7 @@ impl Decoder { self.at = end; Ok(text.into()) } - fn end(self) -> Result<(), RecordingError> { + pub(super) fn end(self) -> Result<(), RecordingError> { if self.at != self.data.len() { Err(RecordingError::Invalid("configured trailing metadata")) } else { diff --git a/src/recording/provider/runtime.rs b/src/recording/provider/runtime.rs index 9467717a..50be9627 100644 --- a/src/recording/provider/runtime.rs +++ b/src/recording/provider/runtime.rs @@ -316,6 +316,8 @@ impl RuntimeTermination { } pub fn decode(run: &RecordingRun, status: &RecordingStatus) -> Result { + let workflow = crate::recording::workflow::split_terminal_status(status)?; + let status = workflow.as_ref().map_or(status, |(status, _, _)| status); if status.outcome == "active" { return Err(RecordingError::Unsupported("runtime active termination")); } @@ -353,6 +355,14 @@ impl RuntimeTermination { source, }; termination.validate(run)?; + if let Some((_, attempt, source)) = &workflow { + crate::recording::workflow::validate_terminal_binding( + run, + &termination, + attempt, + *source, + )?; + } if status.outcome != cause.outcome() || status.accepted_generation != Some(accepted_generation) { @@ -407,7 +417,7 @@ impl RuntimeTermination { Ok(()) } - fn encode(&self) -> Result { + pub(in crate::recording) fn encode(&self) -> Result { let mut encoder = Encoder::default(); encoder.0.extend_from_slice(&self.source); encoder.u8(match self.cause { @@ -789,7 +799,7 @@ fn get_context(decoder: &mut Decoder) -> Result Result<(), RecordingError> { @@ -807,7 +817,9 @@ fn put_binding( encoder.string(&binding.runtime)?; encoder.string(&binding.source_revision) } -fn get_binding(decoder: &mut Decoder) -> Result { +pub(in crate::recording) fn get_binding( + decoder: &mut Decoder, +) -> Result { Ok(ContextProviderIdentity { id: decoder.string()?, version: decoder.string()?, diff --git a/src/recording/workflow.rs b/src/recording/workflow.rs new file mode 100644 index 00000000..9cd2db49 --- /dev/null +++ b/src/recording/workflow.rs @@ -0,0 +1,1466 @@ +//! Additive bounded workflow custody inside the existing SQLite v1 envelope. +//! RCP4 wraps an unchanged RCP2/RCP3 publication; RCT2 wraps unchanged RCT1 +//! terminal facts. Failed attempts are terminal diagnostics, never accepted rows. +use super::provider::runtime::{ + self, RuntimeObservation, RuntimeTermination, RuntimeTerminationAudit, RuntimeTerminationCause, +}; +use super::provider::{ + self, Decoder, Encoder, RecordedConfiguredReceipt, RecordedPressureSelection, +}; +use super::{ + RecordedStep, RecordingError, RecordingReader, RecordingRun, RecordingStatus, StepRecorder, +}; +use crate::workflow::{ + AttemptOutcome, MAX_NODES, NodeKind, NodeReceipt, NodeStatus, PressureFacts, WorkflowAttempt, + WorkflowDescriptor, +}; +use crate::{ + GridGeometry, PressureImplementation, PressureReport, PressureSettings, SessionSnapshot, +}; +use std::sync::Arc; + +pub const PUBLICATION_PROFILE: &str = "RCP4:"; +pub const TERMINAL_PROFILE: &str = "RCT2:"; + +pub(super) type PublicationEnvelope = (String, Arc, [u8; 32]); +pub(super) type TerminalEnvelope = (RecordingStatus, Option>, [u8; 32]); + +/// Publication evidence remains unavailable for historical profiles. Decode the +/// complete numerical publication before returning its associated graph facts. +pub fn publication( + run: &RecordingRun, + step: &RecordedStep, +) -> Result>, RecordingError> { + if run.solver != provider::PROFILE { + if (run.solver.starts_with("rheon-carrier-full-box-v1/") + || run.solver.starts_with("rheon-sph-preview/")) + && !step.controls.starts_with("RCP") + { + return Ok(None); + } + return Err(RecordingError::Unsupported( + "workflow publication source profile", + )); + } + provider::RecordedConfiguredSession::decode(run, step)?; + Ok(split_publication_controls(&step.controls)?.map(|(_, attempt, _)| attempt)) +} + +/// Explicit selection-time validation against the immediately preceding retained +/// native state. Reads at most one additional row; cached paint does no I/O. +/// An archive without the prior publication cannot supply this extra proof. +pub fn selection( + reader: &RecordingReader, + step: &RecordedStep, +) -> Result>, RecordingError> { + let evidence = publication(reader.run(), step)?; + if let Some(attempt) = &evidence + && step.ordinal > 0 + { + let previous = reader.select(step.ordinal - 1)?; + let native = provider::RecordedConfiguredSession::decode(reader.run(), &previous)?; + if let Some((_, prior, _)) = split_publication_controls(&previous.controls)? + && attempt.attempt_id <= prior.attempt_id + { + return Err(RecordingError::Invalid( + "workflow selected attempt identity", + )); + } + if native.generation != attempt.accepted_generation + || native.time.to_bits() != attempt.interval_start.to_bits() + || native_state_digest(&native)? != attempt.input_sha256 + { + return Err(RecordingError::Invalid( + "workflow selected prior native input", + )); + } + } + Ok(evidence) +} + +pub fn configured_publication_with_workflow( + snapshot: &SessionSnapshot<'_>, + ordinal: usize, + limit: usize, + runtime: Option<&RuntimeObservation>, +) -> Result { + let mut step = + provider::configured_publication_with_runtime(snapshot, ordinal, limit, runtime)?; + if let Some(attempt) = snapshot + .configured_provider_report() + .and_then(|report| report.workflow.as_ref()) + { + let mut encoder = Encoder::default(); + encoder + .0 + .extend_from_slice(&provider::source_binding(&provider::configured_run( + snapshot, + "workflow source", + )?)); + encoder.boolean(step.controls.starts_with("RCP3:")); + put_wrapped( + &mut encoder, + &step.controls, + if step.controls.starts_with("RCP3:") { + "RCP3:" + } else { + "RCP2:" + }, + )?; + put_attempt(&mut encoder, attempt)?; + step.controls = encoder.text(PUBLICATION_PROFILE)?; + if step.payload_bytes()? > limit { + return Err(RecordingError::Limit("workflow publication")); + } + provider::RecordedConfiguredSession::decode( + &provider::configured_run(snapshot, "workflow binding")?, + &step, + )?; + } + Ok(step) +} + +pub(super) fn split_publication_controls( + controls: &str, +) -> Result, RecordingError> { + if !controls.starts_with(PUBLICATION_PROFILE) { + return Ok(None); + } + let mut decoder = Decoder::text(controls, PUBLICATION_PROFILE)?; + let source = decoder.array()?; + let runtime = decoder.boolean()?; + let original = get_wrapped(&mut decoder, if runtime { "RCP3:" } else { "RCP2:" })?; + if !original.starts_with("RCP2:") && !original.starts_with("RCP3:") { + return Err(RecordingError::Unsupported( + "workflow wrapped publication profile", + )); + } + let attempt = Arc::new(get_attempt(&mut decoder)?); + decoder.end()?; + Ok(Some((original, attempt, source))) +} + +fn validate_source(run: &RecordingRun, attempt: &WorkflowAttempt) -> Result<(), RecordingError> { + attempt.validate().map_err(RecordingError::Invalid)?; + let config = provider::configured_configuration(run)?; + let d = &attempt.descriptor; + let p = config.transport.carrier.pressure; + if attempt.run_id != run.identity.run_id + || d.grid.counts().map(|n| n as u64) != run.counts + || d.grid.spacing().map(f64::to_bits) != run.spacing.map(f64::to_bits) + || d.grid.origin().map(f64::to_bits) != run.origin.map(f64::to_bits) + || d.density.to_bits() != config.transport.carrier.density.to_bits() + || d.implementation != config.implementation + || d.pressure.max_iterations != p.max_iterations + || [ + d.pressure.relative_residual, + d.pressure.absolute_residual, + d.pressure.divergence_limit, + d.actual_divergence_limit, + d.max_courant, + ] + .map(f64::to_bits) + != [ + p.relative_residual, + p.absolute_residual, + p.divergence_limit, + config.transport.carrier.actual_divergence_limit, + config.transport.carrier.max_courant, + ] + .map(f64::to_bits) + || attempt.attempted_generation > config.max_steps as u64 + { + return Err(RecordingError::Invalid("workflow source association")); + } + Ok(()) +} + +/// Reuse the numerical owner's canonical native-input digest over the exact +/// decoded fields. Constructor pressure is explicitly zero in this profile. +pub(super) fn native_state_digest( + recorded: &provider::RecordedConfiguredSession, +) -> Result<[u8; 32], RecordingError> { + let mut initial_pressure = Vec::new(); + let pressure = if let Some((_, pressure)) = &recorded.interval_pressure { + pressure.as_slice() + } else { + initial_pressure + .try_reserve_exact(recorded.grid.cell_len()) + .map_err(|_| RecordingError::Limit("workflow constructor pressure"))?; + initial_pressure.resize(recorded.grid.cell_len(), 0.0); + initial_pressure.as_slice() + }; + Ok(crate::workflow::input_digest(crate::LiquidTransportView { + carrier: crate::StateView { + x: &recorded.x, + y: &recorded.y, + z: &recorded.z, + tracer: &recorded.tracer, + time: recorded.time, + generation: recorded.generation, + }, + liquid: crate::LiquidVolumeView { + fraction: &recorded.fraction, + stamp: recorded.fraction_stamp, + time: recorded.fraction_time, + density: recorded.configuration.transport.represented_density, + }, + pressure, + carrier_stamp: recorded.carrier_stamp, + pressure_surface: None, + reconstructed_surface: None, + pressure_columns: None, + flat_column_mac: false, + })) +} + +pub(super) fn validate_publication( + run: &RecordingRun, + step: &RecordedStep, + attempt: &WorkflowAttempt, + receipt: Option<&RecordedConfiguredReceipt>, +) -> Result<(), RecordingError> { + validate_source(run, attempt)?; + let receipt = receipt.ok_or(RecordingError::Invalid("workflow without accepted receipt"))?; + if attempt.outcome != AttemptOutcome::Accepted + || attempt.publication != Some(step.generation) + || attempt.actual_dt.map(f64::to_bits) != step.dt.map(f64::to_bits) + || attempt + .actual_dt + .is_none_or(|dt| (attempt.interval_start + dt).to_bits() != step.time.to_bits()) + || attempt.descriptor.viscosity.is_some() != receipt.viscosity_applied + || attempt.descriptor.viscosity.unwrap_or(0.0).to_bits() + != receipt.dynamic_viscosity.to_bits() + { + return Err(RecordingError::Invalid("workflow publication association")); + } + let pressure = attempt + .pressure + .as_ref() + .ok_or(RecordingError::Invalid("workflow accepted pressure absent"))?; + let selected = match receipt.selected { + RecordedPressureSelection::Native(method) => method.id(), + RecordedPressureSelection::Configured => "configured-pressure-context-v1", + }; + if pressure.selected != selected + || pressure.binding != receipt.binding + || pressure.prediction_called != receipt.prediction_called + || pressure.accepted.map(report_bits) != Some(report_bits(receipt.accepted)) + || pressure.candidate.map(report_bits) != receipt.candidate.map(report_bits) + || receipt + .context + .as_ref() + .is_some_and(|c| pressure.rhs_sha256 != Some(c.rhs_sha256)) + || pressure.rejection + != receipt + .rejection + .as_ref() + .map(|rejection| format!("{rejection:?}")) + || pressure.pressure_call_ns != u64::try_from(receipt.total.as_nanos()).ok() + { + return Err(RecordingError::Invalid( + "workflow pressure receipt association", + )); + } + Ok(()) +} + +fn validate_runtime_outcome( + attempt: &WorkflowAttempt, + observation: &RuntimeObservation, +) -> Result<(), RecordingError> { + use super::diagnostic::NoCandidateReason; + let pressure = attempt + .pressure + .as_ref() + .ok_or(RecordingError::Invalid("workflow runtime pressure absent"))?; + let returned = attempt.nodes.iter().any(|node| { + node.node == NodeKind::PressureCandidate && node.status == NodeStatus::CandidateReturned + }); + let rejected = attempt.nodes.iter().any(|node| { + node.node == NodeKind::PressureCandidate && node.status == NodeStatus::Rejected + }); + let valid = match &observation.outcome { + runtime::RuntimeOutcome::CandidateReturned => { + returned + && !rejected + && pressure.rejection.as_deref().is_none_or(|reason| { + matches!(reason, "QualityGate" | "NonFiniteOutput" | "NonZeroGauge") + }) + } + runtime::RuntimeOutcome::NoCandidate { reason, .. } => { + !returned + && rejected + && pressure.candidate.is_none() + && match reason { + NoCandidateReason::Cancelled => { + pressure.rejection.as_deref() == Some("Cancelled") + } + NoCandidateReason::Unsupported | NoCandidateReason::UnsupportedContext => { + pressure + .rejection + .as_deref() + .is_some_and(|reason| reason.starts_with("Unsupported(")) + } + NoCandidateReason::RuntimeFailure | NoCandidateReason::InvalidOutput => { + pressure + .rejection + .as_deref() + .is_some_and(|reason| reason.starts_with("Execution(")) + } + } + } + }; + if !valid { + return Err(RecordingError::Invalid( + "workflow runtime outcome association", + )); + } + Ok(()) +} + +/// Terminal workflow facts have a separate publication association and may +/// describe an attempt after generation zero with no recorded publication. +#[derive(Debug, Clone)] +pub struct WorkflowTerminationAudit { + pub runtime: RuntimeTerminationAudit, + pub attempt: Option>, +} + +pub struct WorkflowTermination { + runtime: RuntimeTermination, + attempt: Option>, + source: [u8; 32], +} +impl WorkflowTermination { + pub fn capture( + snapshot: &SessionSnapshot<'_>, + cause: RuntimeTerminationCause, + detail: &str, + runtime: Option<&RuntimeObservation>, + attempt: Option<&Arc>, + ) -> Result { + let runtime = RuntimeTermination::capture(snapshot, cause, detail, runtime)?; + let value = Self { + runtime, + attempt: attempt.cloned(), + source: provider::source_binding(&provider::configured_run( + snapshot, + "workflow terminal source", + )?), + }; + value.validate(&provider::configured_run( + snapshot, + "workflow terminal source", + )?)?; + value.encode()?; + Ok(value) + } + fn validate(&self, run: &RecordingRun) -> Result<(), RecordingError> { + if self.source != provider::source_binding(run) { + return Err(RecordingError::Invalid("workflow terminal source")); + } + if let Some(attempt) = &self.attempt { + validate_source(run, attempt)?; + if let Some(observation) = &self.runtime.observation { + let pressure = attempt.pressure.as_ref().ok_or(RecordingError::Invalid( + "workflow terminal runtime facts absent", + ))?; + if observation.request.context.generation != attempt.attempted_generation + || observation.request.context.interval_start.to_bits() + != attempt.interval_start.to_bits() + || Some(observation.request.context.dt.to_bits()) + != attempt.actual_dt.map(f64::to_bits) + || pressure.binding.as_ref() != Some(&observation.request.binding) + || pressure.rhs_sha256 != Some(observation.request.context.rhs_sha256) + || !pressure.prediction_called + { + return Err(RecordingError::Invalid( + "workflow terminal runtime association", + )); + } + validate_runtime_outcome(attempt, observation)?; + } + if let Some(generation) = attempt.publication { + if generation != self.runtime.accepted_generation + || attempt.actual_dt.is_none_or(|dt| { + (attempt.interval_start + dt).to_bits() + != self.runtime.accepted_time.to_bits() + }) + { + return Err(RecordingError::Invalid( + "workflow terminal accepted association", + )); + } + } else if attempt.accepted_generation != self.runtime.accepted_generation + || attempt.interval_start.to_bits() != self.runtime.accepted_time.to_bits() + || self.runtime.cause == RuntimeTerminationCause::Completed + { + return Err(RecordingError::Invalid( + "workflow terminal attempted association", + )); + } + } + Ok(()) + } + fn encode(&self) -> Result { + let mut encoder = Encoder::default(); + encoder.0.extend_from_slice(&self.source); + put_wrapped(&mut encoder, &self.runtime.encode()?, "RCT1:")?; + encoder.boolean(self.attempt.is_some()); + if let Some(attempt) = &self.attempt { + put_attempt(&mut encoder, attempt)?; + } + encoder.text(TERMINAL_PROFILE) + } + pub fn finish(&self, recorder: &mut StepRecorder) -> Result<(), RecordingError> { + self.validate(recorder.run())?; + let status = match self.runtime.cause { + RuntimeTerminationCause::Completed => "completed", + RuntimeTerminationCause::Cancelled => "cancelled", + _ => "failed", + }; + recorder.finish( + status, + &self.encode()?, + Some(self.runtime.accepted_generation), + ) + } +} + +pub(super) fn split_terminal_status( + status: &RecordingStatus, +) -> Result, RecordingError> { + if !status.detail.starts_with(TERMINAL_PROFILE) { + return Ok(None); + } + let mut decoder = Decoder::text(&status.detail, TERMINAL_PROFILE)?; + let source = decoder.array()?; + let detail = get_wrapped(&mut decoder, "RCT1:")?; + if !detail.starts_with("RCT1:") { + return Err(RecordingError::Unsupported( + "workflow wrapped terminal profile", + )); + } + let attempt = if decoder.boolean()? { + Some(Arc::new(get_attempt(&mut decoder)?)) + } else { + None + }; + decoder.end()?; + Ok(Some(( + RecordingStatus { + detail, + ..status.clone() + }, + attempt, + source, + ))) +} + +pub(super) fn validate_terminal_binding( + run: &RecordingRun, + runtime: &RuntimeTermination, + attempt: &Option>, + source: [u8; 32], +) -> Result<(), RecordingError> { + WorkflowTermination { + runtime: runtime.clone(), + attempt: attempt.clone(), + source, + } + .validate(run) +} + +pub fn terminal( + reader: &RecordingReader, +) -> Result, RecordingError> { + let status = reader.status()?; + let Some((_, attempt, source)) = split_terminal_status(&status)? else { + if status.detail.starts_with("RCT1:") { + runtime::audit_runtime_termination(reader)?; + } else if status.detail.starts_with("RCT") { + return Err(RecordingError::Unsupported("workflow terminal version")); + } + return Ok(None); + }; + let runtime = runtime::audit_runtime_termination(reader)?; + WorkflowTermination { + runtime: runtime.termination.clone(), + attempt: attempt.clone(), + source, + } + .validate(reader.run())?; + if let Some(attempt) = &attempt { + let count = reader.count()?; + if count > 0 { + let row = reader.select(count - 1)?; + if attempt.publication != Some(row.generation) + && let Some((_, prior, _)) = split_publication_controls(&row.controls)? + && attempt.attempt_id <= prior.attempt_id + { + return Err(RecordingError::Invalid( + "workflow terminal attempt identity", + )); + } + if row.generation == attempt.accepted_generation { + let prior = provider::RecordedConfiguredSession::decode(reader.run(), &row)?; + if prior.time.to_bits() != attempt.interval_start.to_bits() + || native_state_digest(&prior)? != attempt.input_sha256 + { + return Err(RecordingError::Invalid("workflow terminal native input")); + } + } + if attempt.publication == Some(row.generation) { + if let Some(recorded) = publication(reader.run(), &row)? { + let mut a = Encoder::default(); + let mut b = Encoder::default(); + put_attempt(&mut a, attempt)?; + put_attempt(&mut b, &recorded)?; + if a.0 != b.0 { + return Err(RecordingError::Invalid( + "workflow terminal publication differs", + )); + } + } else { + return Err(RecordingError::Invalid( + "workflow terminal accepted evidence missing", + )); + } + } + } + } + Ok(Some(WorkflowTerminationAudit { runtime, attempt })) +} + +// Store the existing hex profile's decoded payload once. This preserves its +// exact bytes while avoiding nested hex expansion and the 1000-byte text cap. +fn put_wrapped(e: &mut Encoder, text: &str, profile: &str) -> Result<(), RecordingError> { + let original = Decoder::text(text, profile)?; + e.u32(original.data.len() as u32); + e.0.extend_from_slice(&original.data); + Ok(()) +} +fn get_wrapped(d: &mut Decoder, profile: &str) -> Result { + let len = d.u32()? as usize; + if len > super::MAX_TEXT / 2 { + return Err(RecordingError::Limit("workflow wrapped metadata")); + } + let mut bytes = Vec::with_capacity(len); + for _ in 0..len { + bytes.push(d.u8()?); + } + Encoder(bytes).text(profile) +} + +fn report_bits(report: PressureReport) -> [u64; 4] { + [ + report.iterations as u64, + report.true_residual_l2.to_bits(), + report.true_residual_max.to_bits(), + report.predicted_divergence_max.to_bits(), + ] +} +fn put_report(e: &mut Encoder, report: PressureReport) { + for n in report_bits(report) { + e.u64(n); + } +} +fn get_report(d: &mut Decoder) -> Result { + Ok(PressureReport { + iterations: d.usize()?, + true_residual_l2: d.f64()?, + true_residual_max: d.f64()?, + predicted_divergence_max: d.f64()?, + }) +} +fn put_f64(e: &mut Encoder, value: Option) { + e.boolean(value.is_some()); + if let Some(v) = value { + e.f64(v); + } +} +fn get_f64(d: &mut Decoder) -> Result, RecordingError> { + Ok(if d.boolean()? { Some(d.f64()?) } else { None }) +} +fn put_attempt(e: &mut Encoder, a: &WorkflowAttempt) -> Result<(), RecordingError> { + a.validate().map_err(RecordingError::Invalid)?; + let w = &a.descriptor; + e.u32(w.version); + for n in w.grid.counts() { + e.usize(n); + } + for v in w.grid.spacing().into_iter().chain(w.grid.origin()) { + e.f64(v); + } + e.f64(w.density); + put_f64(e, w.viscosity); + e.u8(match w.implementation { + PressureImplementation::JacobiPcgV1 => 0, + PressureImplementation::SymmetricGaussSeidelPcgV1 => 1, + }); + e.f64(w.pressure.relative_residual); + e.f64(w.pressure.absolute_residual); + e.f64(w.pressure.divergence_limit); + e.usize(w.pressure.max_iterations); + e.f64(w.actual_divergence_limit); + e.f64(w.max_courant); + e.0.extend_from_slice(&a.descriptor_sha256); + e.0.extend_from_slice(&a.input_sha256); + e.0.extend_from_slice(&a.request_sha256); + e.usize(a.force_count); + e.boolean(a.smoke_source_present); + e.boolean(a.volume_source_present); + for v in [ + a.run_id, + a.attempt_id, + a.accepted_generation, + a.attempted_generation, + ] { + e.u64(v); + } + e.f64(a.interval_start); + e.f64(a.requested_dt); + put_f64(e, a.actual_dt); + e.boolean(a.publication.is_some()); + if let Some(v) = a.publication { + e.u64(v); + } + e.u8(match a.outcome { + AttemptOutcome::Accepted => 0, + AttemptOutcome::Cancelled => 1, + AttemptOutcome::Failed => 2, + AttemptOutcome::Refused => 3, + }); + e.usize(a.nodes.len()); + for n in &a.nodes { + e.u8(n.node as u8); + e.u8(match n.status { + NodeStatus::Started => 5, + NodeStatus::Completed => 0, + NodeStatus::CandidateReturned => 1, + NodeStatus::Rejected => 2, + NodeStatus::Selected => 3, + NodeStatus::Accepted => 4, + }); + } + e.boolean(a.pressure.is_some()); + if let Some(p) = &a.pressure { + e.string(&p.selected)?; + e.boolean(p.binding.is_some()); + if let Some(b) = &p.binding { + runtime::put_binding(e, b)?; + } + e.boolean(p.prediction_called); + e.u8(match p.support { + None => 0, + Some(false) => 1, + Some(true) => 2, + }); + e.boolean(p.rejection.is_some()); + if let Some(r) = &p.rejection { + e.string(r)?; + } + e.boolean(p.rhs_sha256.is_some()); + if let Some(h) = p.rhs_sha256 { + e.0.extend_from_slice(&h); + } + for r in [p.candidate, p.accepted] { + e.boolean(r.is_some()); + if let Some(r) = r { + put_report(e, r); + } + } + e.boolean(p.pressure_call_ns.is_some()); + if let Some(v) = p.pressure_call_ns { + e.u64(v); + } + put_f64(e, p.residual_l2_limit); + } + put_f64(e, a.actual_divergence_max); + e.string(&a.diagnostic) +} +fn get_attempt(d: &mut Decoder) -> Result { + let version = d.u32()?; + if version != crate::workflow::DESCRIPTOR_VERSION { + return Err(RecordingError::Unsupported("workflow descriptor version")); + } + let counts = [d.u64()?, d.u64()?, d.u64()?]; + let spacing = [d.f64()?, d.f64()?, d.f64()?]; + let origin = [d.f64()?, d.f64()?, d.f64()?]; + let grid = GridGeometry::new(counts, spacing, origin) + .map_err(|_| RecordingError::Invalid("workflow geometry"))?; + let density = d.f64()?; + let viscosity = get_f64(d)?; + let implementation = match d.u8()? { + 0 => PressureImplementation::JacobiPcgV1, + 1 => PressureImplementation::SymmetricGaussSeidelPcgV1, + _ => return Err(RecordingError::Unsupported("workflow pressure method")), + }; + let pressure = PressureSettings { + relative_residual: d.f64()?, + absolute_residual: d.f64()?, + divergence_limit: d.f64()?, + max_iterations: d.usize()?, + }; + let descriptor = WorkflowDescriptor { + version, + grid, + density, + viscosity, + implementation, + pressure, + actual_divergence_limit: d.f64()?, + max_courant: d.f64()?, + }; + let descriptor_sha256 = d.array()?; + let input_sha256 = d.array()?; + let request_sha256 = d.array()?; + let force_count = d.usize()?; + let smoke_source_present = d.boolean()?; + let volume_source_present = d.boolean()?; + let run_id = d.u64()?; + let attempt_id = d.u64()?; + let accepted_generation = d.u64()?; + let attempted_generation = d.u64()?; + let interval_start = d.f64()?; + let requested_dt = d.f64()?; + let actual_dt = get_f64(d)?; + let publication = if d.boolean()? { Some(d.u64()?) } else { None }; + let outcome = match d.u8()? { + 0 => AttemptOutcome::Accepted, + 1 => AttemptOutcome::Cancelled, + 2 => AttemptOutcome::Failed, + 3 => AttemptOutcome::Refused, + _ => return Err(RecordingError::Unsupported("workflow outcome")), + }; + let count = d.usize()?; + if count > MAX_NODES { + return Err(RecordingError::Limit("workflow node count")); + } + let mut nodes = Vec::with_capacity(count); + for _ in 0..count { + let node = *NodeKind::ALL + .get(d.u8()? as usize) + .ok_or(RecordingError::Unsupported("workflow node kind"))?; + let status = match d.u8()? { + 5 => NodeStatus::Started, + 0 => NodeStatus::Completed, + 1 => NodeStatus::CandidateReturned, + 2 => NodeStatus::Rejected, + 3 => NodeStatus::Selected, + 4 => NodeStatus::Accepted, + _ => return Err(RecordingError::Unsupported("workflow node status")), + }; + nodes.push(NodeReceipt { node, status }); + } + let pressure = if d.boolean()? { + let selected = d.string()?; + let binding = if d.boolean()? { + Some(runtime::get_binding(d)?) + } else { + None + }; + let prediction_called = d.boolean()?; + let support = match d.u8()? { + 0 => None, + 1 => Some(false), + 2 => Some(true), + _ => return Err(RecordingError::Unsupported("workflow support")), + }; + let rejection = if d.boolean()? { + Some(d.string()?) + } else { + None + }; + let rhs_sha256 = if d.boolean()? { Some(d.array()?) } else { None }; + let candidate = if d.boolean()? { + Some(get_report(d)?) + } else { + None + }; + let accepted = if d.boolean()? { + Some(get_report(d)?) + } else { + None + }; + let pressure_call_ns = if d.boolean()? { Some(d.u64()?) } else { None }; + let residual_l2_limit = get_f64(d)?; + Some(PressureFacts { + selected, + binding, + prediction_called, + support, + rejection, + rhs_sha256, + candidate, + accepted, + pressure_call_ns, + residual_l2_limit, + }) + } else { + None + }; + let attempt = WorkflowAttempt { + descriptor, + descriptor_sha256, + input_sha256, + request_sha256, + force_count, + smoke_source_present, + volume_source_present, + run_id, + attempt_id, + accepted_generation, + attempted_generation, + interval_start, + requested_dt, + actual_dt, + publication, + outcome, + nodes, + pressure, + actual_divergence_max: get_f64(d)?, + diagnostic: d.string()?, + }; + attempt.validate().map_err(RecordingError::Invalid)?; + Ok(attempt) +} + +#[cfg(test)] +mod tests { + use super::super::RecordingLimits; + use super::*; + use crate::{ + LiquidInlet, LiquidStepInputs, LiquidTransportConfig, LiquidVolumeSettings, + ProviderStepWorkspace, SessionIdentity, SimulationConfig, SimulationSession, VolumeStamp, + }; + use std::sync::atomic::{AtomicU64, Ordering}; + const CAP: usize = 1024 * 1024; + fn fixture() -> (SimulationSession, ProviderStepWorkspace) { + let grid = GridGeometry::new([2; 3], [0.5; 3], [0.0; 3]).unwrap(); + let config = LiquidTransportConfig { + carrier: SimulationConfig { + density: 1000.0, + memory_limit: CAP, + pressure: PressureSettings { + relative_residual: 1e-12, + absolute_residual: 1e-12, + divergence_limit: 1e-9, + max_iterations: 1000, + }, + actual_divergence_limit: 1e-5, + max_courant: 1.0, + }, + represented_density: 1000.0, + volume_stamp: VolumeStamp { id: 1, version: 0 }, + carrier_id: 2, + }; + let workspace = + ProviderStepWorkspace::new(&grid, 1000.0, CAP, PressureImplementation::JacobiPcgV1) + .unwrap(); + ( + SimulationSession::new( + SessionIdentity { + run_id: 711, + source_revision: "workflow-custody-fixture".into(), + }, + grid, + config, + PressureImplementation::JacobiPcgV1, + vec![1.0; 8], + CAP, + 4, + ) + .unwrap(), + workspace, + ) + } + fn inputs() -> LiquidStepInputs<'static> { + LiquidStepInputs { + requested_dt: 0.0625, + smoke_source: None, + forces: &[], + source: None, + inlet: LiquidInlet::new(VolumeStamp { id: 3, version: 0 }, [[0.0; 2]; 3]).unwrap(), + volume: LiquidVolumeSettings { + max_outward_courant: 1.0, + actual_divergence_limit: 1e-5, + }, + } + } + fn path() -> std::path::PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!( + "rheon-workflow-{}-{}.sqlite", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )) + } + fn wrap(run: &RecordingRun, original: &str, attempt: &WorkflowAttempt) -> String { + let mut e = Encoder::default(); + e.0.extend_from_slice(&provider::source_binding(run)); + e.boolean(original.starts_with("RCP3:")); + put_wrapped( + &mut e, + original, + if original.starts_with("RCP3:") { + "RCP3:" + } else { + "RCP2:" + }, + ) + .unwrap(); + put_attempt(&mut e, attempt).unwrap(); + e.text(PUBLICATION_PROFILE).unwrap() + } + #[test] + fn accepted_round_trip_and_legacy_profiles_preserve_original_meaning() { + let (mut session, mut workspace) = fixture(); + session + .step_with_configured_pressure(inputs(), &mut workspace, None, None, None, |_| false) + .unwrap(); + let snapshot = session.snapshot(); + let run = provider::configured_run(&snapshot, "fixture").unwrap(); + let legacy = provider::configured_publication(&snapshot, 0, CAP).unwrap(); + assert!(legacy.controls.starts_with("RCP2:")); + assert!(publication(&run, &legacy).unwrap().is_none()); + let step = configured_publication_with_workflow(&snapshot, 0, CAP, None).unwrap(); + let (original, expected, _) = split_publication_controls(&step.controls).unwrap().unwrap(); + assert_eq!(original, legacy.controls); + let decoded = publication(&run, &step).unwrap().unwrap(); + assert_eq!(decoded.descriptor_sha256, expected.descriptor_sha256); + assert_eq!(decoded.input_sha256, expected.input_sha256); + assert_eq!(decoded.request_sha256, expected.request_sha256); + assert_eq!(decoded.force_count, expected.force_count); + assert_eq!(decoded.publication, Some(1)); + assert_eq!(decoded.accepted_generation, 0); + let archive = path(); + let limits = RecordingLimits::default(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + recorder.append(&step).unwrap(); + WorkflowTermination::capture( + &snapshot, + RuntimeTerminationCause::Completed, + "done", + None, + Some(&decoded), + ) + .unwrap() + .finish(&mut recorder) + .unwrap(); + drop(recorder); + drop(session); + let reader = RecordingReader::open(&archive, limits).unwrap(); + let terminal = terminal(&reader).unwrap().unwrap(); + assert_eq!(terminal.runtime.termination.accepted_generation, 1); + assert_eq!(terminal.attempt.unwrap().publication, Some(1)); + provider::verify_configured_recording(&reader).unwrap(); + drop(reader); + std::fs::remove_file(archive).unwrap(); + } + struct CandidateFixture { + invalid: bool, + observation: Option, + } + impl crate::solver_provider::configured::ContextPressureProvider for CandidateFixture { + fn identity(&self) -> Arc { + Arc::new( + crate::solver_provider::configured::ContextProviderIdentity { + id: "workflow-classical-fixture".into(), + version: "1".into(), + manifest_sha256: [3; 32], + checkpoint_sha256: None, + training_run_id: None, + runtime: "native-fixture".into(), + source_revision: "fixture-source".into(), + }, + ) + } + fn supports( + &self, + _: &crate::solver_provider::configured::ContextPressureRequest<'_>, + ) -> Result<(), crate::solver_provider::ProviderRejection> { + Ok(()) + } + fn predict( + &mut self, + request: &crate::solver_provider::configured::ContextPressureRequest<'_>, + output: &mut [f64], + _: &mut dyn FnMut() -> bool, + ) -> Result<(), crate::solver_provider::ProviderRejection> { + self.observation = Some(RuntimeObservation { + request: runtime::RuntimeRequestStamp::capture(request, &self.identity()).unwrap(), + outcome: runtime::RuntimeOutcome::CandidateReturned, + }); + output.fill(if self.invalid { f64::NAN } else { 0.0 }); + Ok(()) + } + } + #[test] + fn candidate_and_fallback_runtime_profiles_round_trip_actual_provider_identity() { + for invalid in [false, true] { + let (mut session, mut workspace) = fixture(); + let mut provider = CandidateFixture { + invalid, + observation: None, + }; + let mut viscosity = + crate::ViscosityWorkspace::new(session.snapshot().grid().clone(), CAP).unwrap(); + let mut evidence = None; + session + .step_with_configured_pressure_observed( + inputs(), + &mut workspace, + Some((&mut viscosity, 0.0)), + Some(&mut provider), + None, + |_| false, + 6, + &mut evidence, + ) + .unwrap(); + let evidence = evidence.unwrap(); + let run = provider::configured_run(&session.snapshot(), "runtime fixture").unwrap(); + let old = provider::configured_publication_with_runtime( + &session.snapshot(), + 0, + CAP, + provider.observation.as_ref(), + ) + .unwrap(); + assert!(old.controls.starts_with("RCP3:")); + assert!(publication(&run, &old).unwrap().is_none()); + let row = configured_publication_with_workflow( + &session.snapshot(), + 0, + CAP, + provider.observation.as_ref(), + ) + .unwrap(); + let recorded = publication(&run, &row).unwrap().unwrap(); + assert_eq!(recorded.descriptor.viscosity, Some(0.0)); + assert_eq!( + recorded.pressure.as_ref().unwrap().rejection.is_some(), + invalid + ); + assert_eq!( + recorded + .pressure + .as_ref() + .unwrap() + .binding + .as_ref() + .unwrap() + .id, + "workflow-classical-fixture" + ); + let archive = path(); + let limits = RecordingLimits::default(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + recorder.append(&row).unwrap(); + WorkflowTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::Completed, + "done", + provider.observation.as_ref(), + Some(&evidence), + ) + .unwrap() + .finish(&mut recorder) + .unwrap(); + drop(recorder); + drop(session); + drop(provider); + let reader = RecordingReader::open(&archive, limits).unwrap(); + let retained = terminal(&reader).unwrap().unwrap(); + assert_eq!(retained.attempt.unwrap().attempt_id, 6); + assert!(retained.runtime.termination.observation.is_some()); + drop(reader); + std::fs::remove_file(archive).unwrap(); + } + } + + #[test] + fn cancellation_at_generation_zero_is_cold_inspectable_without_accepted_row() { + for final_checkpoint in [false, true] { + let (mut session, mut workspace) = fixture(); + let mut evidence = None; + assert!( + session + .step_with_configured_pressure_observed( + inputs(), + &mut workspace, + None, + None, + None, + |stage| !final_checkpoint || stage == crate::LiquidStepStage::BeforeCommit, + 9, + &mut evidence + ) + .is_err() + ); + let attempt = evidence.unwrap(); + assert_eq!(attempt.accepted_generation, 0); + assert_eq!(attempt.attempted_generation, 1); + assert_eq!(attempt.publication, None); + assert_eq!(attempt.outcome, AttemptOutcome::Cancelled); + let archive = path(); + let limits = RecordingLimits::default(); + let run = provider::configured_run(&session.snapshot(), "empty cancellation").unwrap(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + WorkflowTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::Cancelled, + "cancelled", + None, + Some(&attempt), + ) + .unwrap() + .finish(&mut recorder) + .unwrap(); + drop(recorder); + drop(session); + let reader = RecordingReader::open(&archive, limits).unwrap(); + assert_eq!(reader.count().unwrap(), 0); + let retained = terminal(&reader).unwrap().unwrap(); + assert_eq!(retained.runtime.termination.accepted_generation, 0); + let retained = retained.attempt.unwrap(); + assert_eq!(retained.attempt_id, 9); + assert_eq!(retained.publication, None); + assert_eq!(retained.pressure.is_some(), final_checkpoint); + drop(reader); + std::fs::remove_file(archive).unwrap(); + } + } + #[test] + fn malformed_cross_linked_and_unknown_workflow_evidence_is_rejected() { + let (mut session, mut workspace) = fixture(); + session + .step_with_configured_pressure(inputs(), &mut workspace, None, None, None, |_| false) + .unwrap(); + let run = provider::configured_run(&session.snapshot(), "fixture").unwrap(); + let mut step = + configured_publication_with_workflow(&session.snapshot(), 0, CAP, None).unwrap(); + let (original, attempt, _) = split_publication_controls(&step.controls).unwrap().unwrap(); + let good = step.controls.clone(); + let mut other = (*attempt).clone(); + other.run_id += 1; + step.controls = wrap(&run, &original, &other); + assert!(publication(&run, &step).is_err()); + let mut other_source = run.clone(); + other_source.identity.source_revision.push_str("-different"); + step.controls = wrap(&other_source, &original, &attempt); + assert!(publication(&run, &step).is_err()); + step.controls = good.clone(); + step.controls.pop(); + assert!(publication(&run, &step).is_err()); + step.controls = good; + step.time += 0.01; + assert!(publication(&run, &step).is_err()); + let mut e = Encoder::default(); + put_attempt(&mut e, &attempt).unwrap(); + e.0[0..4].copy_from_slice(&99u32.to_le_bytes()); + let text = e.text("TEST:").unwrap(); + let mut d = Decoder::text(&text, "TEST:").unwrap(); + assert!(matches!( + get_attempt(&mut d), + Err(RecordingError::Unsupported(_)) + )); + let mut e = Encoder::default(); + e.u32((super::super::MAX_TEXT + 1) as u32); + let text = e.text("TEST:").unwrap(); + let mut d = Decoder::text(&text, "TEST:").unwrap(); + assert!(matches!( + get_wrapped(&mut d, "RCT1:"), + Err(RecordingError::Limit(_)) + )); + } + #[test] + fn terminal_rejects_contradictory_runtime_outcome_and_unknown_version() { + let (mut session, mut workspace) = fixture(); + let mut provider = CandidateFixture { + invalid: false, + observation: None, + }; + let mut evidence = None; + assert!( + session + .step_with_configured_pressure_observed( + inputs(), + &mut workspace, + None, + Some(&mut provider), + None, + |stage| stage == crate::LiquidStepStage::BeforeCommit, + 4, + &mut evidence + ) + .is_err() + ); + let evidence = evidence.unwrap(); + let mut contradiction = provider.observation.clone().unwrap(); + contradiction.outcome = runtime::RuntimeOutcome::NoCandidate { + reason: super::super::diagnostic::NoCandidateReason::Cancelled, + detail: "authored contradiction".into(), + dispatched: true, + invalid_payload_sha256: None, + }; + assert!(matches!( + WorkflowTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::Cancelled, + "cancelled", + Some(&contradiction), + Some(&evidence) + ), + Err(RecordingError::Invalid( + "workflow runtime outcome association" + )) + )); + let mut terminal = WorkflowTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::Cancelled, + "cancelled", + provider.observation.as_ref(), + Some(&evidence), + ) + .unwrap(); + terminal.runtime.observation = Some(contradiction); + let archive = path(); + let limits = RecordingLimits::default(); + let run = provider::configured_run(&session.snapshot(), "runtime contradiction").unwrap(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + recorder + .finish("cancelled", &terminal.encode().unwrap(), Some(0)) + .unwrap(); + drop(recorder); + let reader = RecordingReader::open(&archive, limits).unwrap(); + assert!(matches!( + super::terminal(&reader), + Err(RecordingError::Invalid( + "workflow runtime outcome association" + )) + )); + drop(reader); + std::fs::remove_file(archive).unwrap(); + let archive = path(); + let run = provider::configured_run(&session.snapshot(), "unknown terminal").unwrap(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + recorder.finish("failed", "RCT3:00", Some(0)).unwrap(); + drop(recorder); + let reader = RecordingReader::open(&archive, limits).unwrap(); + assert!(matches!( + super::terminal(&reader), + Err(RecordingError::Unsupported("workflow terminal version")) + )); + drop(reader); + std::fs::remove_file(archive).unwrap(); + } + + #[test] + fn forged_native_input_hash_is_rejected_by_bounded_selection_and_archive_chain() { + let (mut session, mut workspace) = fixture(); + let constructor = provider::configured_publication(&session.snapshot(), 0, CAP).unwrap(); + session + .step_with_configured_pressure(inputs(), &mut workspace, None, None, None, |_| false) + .unwrap(); + let run = provider::configured_run(&session.snapshot(), "input-link fixture").unwrap(); + let mut row = + configured_publication_with_workflow(&session.snapshot(), 1, CAP, None).unwrap(); + let (original, attempt, _) = split_publication_controls(&row.controls).unwrap().unwrap(); + assert_eq!( + native_state_digest( + &provider::RecordedConfiguredSession::decode(&run, &constructor).unwrap() + ) + .unwrap(), + attempt.input_sha256 + ); + let mut forged = (*attempt).clone(); + forged.input_sha256[0] ^= 1; + row.controls = wrap(&run, &original, &forged); + // The row remains intrinsically well formed; only the prior native + // publication proves this reachable cross-link is false. + publication(&run, &row).unwrap(); + let archive = path(); + let limits = RecordingLimits::default(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + recorder.append(&constructor).unwrap(); + recorder.append(&row).unwrap(); + recorder + .finish("failed", "authored cross-link refusal fixture", Some(1)) + .unwrap(); + drop(recorder); + let reader = RecordingReader::open(&archive, limits).unwrap(); + assert!(matches!( + selection(&reader, &row), + Err(RecordingError::Invalid( + "workflow selected prior native input" + )) + )); + assert!(matches!( + provider::verify_configured_recording(&reader), + Err(RecordingError::Invalid("workflow prior native input")) + )); + drop(reader); + std::fs::remove_file(archive).unwrap(); + } + + #[test] + fn duplicate_attempt_ids_are_rejected_for_publications_and_next_terminal() { + let (mut session, mut workspace) = fixture(); + session + .step_with_configured_pressure(inputs(), &mut workspace, None, None, None, |_| false) + .unwrap(); + let first = + configured_publication_with_workflow(&session.snapshot(), 0, CAP, None).unwrap(); + let run = + provider::configured_run(&session.snapshot(), "attempt sequence fixture").unwrap(); + let mut cancelled = None; + assert!( + session + .step_with_configured_pressure_observed( + inputs(), + &mut workspace, + None, + None, + None, + |stage| stage == crate::LiquidStepStage::BeforeCommit, + 1, + &mut cancelled + ) + .is_err() + ); + let archive = path(); + let limits = RecordingLimits::default(); + let mut recorder = StepRecorder::create(&archive, run.clone(), limits).unwrap(); + recorder.append(&first).unwrap(); + WorkflowTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::Cancelled, + "duplicate attempted id", + None, + cancelled.as_ref(), + ) + .unwrap() + .finish(&mut recorder) + .unwrap(); + drop(recorder); + let reader = RecordingReader::open(&archive, limits).unwrap(); + assert!(matches!( + terminal(&reader), + Err(RecordingError::Invalid( + "workflow terminal attempt identity" + )) + )); + drop(reader); + std::fs::remove_file(archive).unwrap(); + session + .step_with_configured_pressure(inputs(), &mut workspace, None, None, None, |_| false) + .unwrap(); + let mut second = + configured_publication_with_workflow(&session.snapshot(), 1, CAP, None).unwrap(); + let (original, attempt, _) = split_publication_controls(&second.controls) + .unwrap() + .unwrap(); + let mut forged = (*attempt).clone(); + forged.attempt_id = 1; + second.controls = wrap(&run, &original, &forged); + let archive = path(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + recorder.append(&first).unwrap(); + recorder.append(&second).unwrap(); + recorder + .finish("failed", "authored attempt-id refusal fixture", Some(2)) + .unwrap(); + drop(recorder); + let reader = RecordingReader::open(&archive, limits).unwrap(); + assert!(matches!( + selection(&reader, &second), + Err(RecordingError::Invalid( + "workflow selected attempt identity" + )) + )); + assert!(matches!( + provider::verify_configured_recording(&reader), + Err(RecordingError::Invalid( + "workflow attempt identity sequence" + )) + )); + drop(reader); + std::fs::remove_file(archive).unwrap(); + } + + #[test] + fn legacy_terminal_is_unavailable_and_storage_failure_preserves_accepted_tail() { + let (mut session, mut workspace) = fixture(); + let limits = RecordingLimits::default(); + let legacy_path = path(); + let run = provider::configured_run(&session.snapshot(), "legacy").unwrap(); + let mut recorder = StepRecorder::create(&legacy_path, run, limits).unwrap(); + RuntimeTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::StartupFailure, + "legacy", + None, + ) + .unwrap() + .finish(&mut recorder) + .unwrap(); + drop(recorder); + let reader = RecordingReader::open(&legacy_path, limits).unwrap(); + assert!(terminal(&reader).unwrap().is_none()); + runtime::audit_runtime_termination(&reader).unwrap(); + drop(reader); + std::fs::remove_file(legacy_path).unwrap(); + let constructor = provider::configured_publication(&session.snapshot(), 0, CAP).unwrap(); + session + .step_with_configured_pressure(inputs(), &mut workspace, None, None, None, |_| false) + .unwrap(); + let evidence = session + .snapshot() + .configured_provider_report() + .unwrap() + .workflow + .clone() + .unwrap(); + for (keep_constructor, forge_input) in [(false, false), (true, false), (true, true)] { + let archive = path(); + let run = provider::configured_run(&session.snapshot(), "unrecorded tail").unwrap(); + let mut recorder = StepRecorder::create(&archive, run, limits).unwrap(); + if keep_constructor { + recorder.append(&constructor).unwrap(); + } + let mut retained = (*evidence).clone(); + if forge_input { + retained.input_sha256[0] ^= 1; + } + WorkflowTermination::capture( + &session.snapshot(), + RuntimeTerminationCause::StorageFailure, + "row not recorded", + None, + Some(&Arc::new(retained)), + ) + .unwrap() + .finish(&mut recorder) + .unwrap(); + drop(recorder); + let reader = RecordingReader::open(&archive, limits).unwrap(); + if forge_input { + assert!(matches!( + terminal(&reader), + Err(RecordingError::Invalid("workflow terminal native input")) + )); + } else { + let audit = terminal(&reader).unwrap().unwrap(); + assert!( + matches!(audit.runtime.coverage,runtime::RuntimeTerminalCoverage::UnrecordedAcceptedTail {last_recorded_generation} if last_recorded_generation == keep_constructor.then_some(0)) + ); + assert_eq!(audit.attempt.unwrap().publication, Some(1)); + } + drop(reader); + std::fs::remove_file(archive).unwrap(); + } + } +} diff --git a/src/session/configured.rs b/src/session/configured.rs index 02f5fa46..b586bdd7 100644 --- a/src/session/configured.rs +++ b/src/session/configured.rs @@ -39,8 +39,50 @@ impl SimulationSession { Instant::now, ) } - #[allow(clippy::too_many_arguments)] // Explicit workspaces and injectable deadline clock. + /// Observe one real attempt, including failures with no accepted publication. + /// The caller owns monotonically increasing attempt ids within this run. + #[allow(clippy::too_many_arguments)] + pub fn step_with_configured_pressure_observed bool>( + &mut self, + inputs: LiquidStepInputs<'_>, + workspace: &mut crate::ProviderStepWorkspace, + viscosity: Option<(&mut crate::ViscosityWorkspace, f64)>, + preferred: Option<&mut dyn ContextPressureProvider>, + deadline: Option, + cancel: F, + attempt_id: u64, + evidence: &mut Option>, + ) -> Result<(LiquidStepReport, ConfiguredProviderReport), SessionError> { + self.configured_step_observed_clock( + inputs, + workspace, + viscosity, + preferred, + deadline, + cancel, + Instant::now, + attempt_id, + evidence, + ) + } + #[allow(clippy::too_many_arguments)] fn configured_step_with_clock bool, N: FnMut() -> Instant>( + &mut self, + inputs: LiquidStepInputs<'_>, + workspace: &mut crate::ProviderStepWorkspace, + viscosity: Option<(&mut crate::ViscosityWorkspace, f64)>, + preferred: Option<&mut dyn ContextPressureProvider>, + deadline: Option, + cancel: F, + now: N, + ) -> Result<(LiquidStepReport, ConfiguredProviderReport), SessionError> { + let attempt_id = self.simulation.state().carrier.generation.saturating_add(1); + self.configured_step_observed_clock( + inputs, workspace, viscosity, preferred, deadline, cancel, now, attempt_id, &mut None, + ) + } + #[allow(clippy::too_many_arguments)] + fn configured_step_observed_clock bool, N: FnMut() -> Instant>( &mut self, inputs: LiquidStepInputs<'_>, workspace: &mut crate::ProviderStepWorkspace, @@ -49,69 +91,160 @@ impl SimulationSession { deadline: Option, mut cancel: F, mut now: N, + attempt_id: u64, + evidence: &mut Option>, ) -> Result<(LiquidStepReport, ConfiguredProviderReport), SessionError> { - if self.stopped { - return Err(SessionError::Stopped); - } - if self.steps >= self.max_steps { - return Err(SessionError::StepLimit); - } - if deadline.is_some_and(|d| now() >= d) { - return Err(SessionError::Deadline); - } - let generation = self - .simulation - .state() - .carrier - .generation - .checked_add(1) - .ok_or(SessionError::PressureBinding( + use crate::workflow::{AttemptOutcome, NodeKind, NodeReceipt, NodeStatus, WorkflowAttempt}; + *evidence = None; + if attempt_id == 0 { + return Err(SessionError::PressureBinding( crate::solver_provider::ProviderRejection::Unsupported( - "pressure generation exhausted", + "workflow attempt identity must be nonzero", ), - ))?; - let run_id = self.identity.run_id.to_string(); + )); + } + let accepted_generation = self.simulation.state().carrier.generation; + let generation = + accepted_generation + .checked_add(1) + .ok_or(SessionError::PressureBinding( + crate::solver_provider::ProviderRejection::Unsupported( + "pressure generation exhausted", + ), + ))?; + let descriptor = self + .simulation + .workflow_descriptor(viscosity.as_ref().map(|(_, mu)| *mu)); let interval_start = self.simulation.state().carrier.time; - let configured = AcceptedContextInput { - run_id, - generation, + let trace = std::rc::Rc::new(std::cell::RefCell::new(WorkflowAttempt { + descriptor_sha256: descriptor.digest(), + descriptor, + input_sha256: crate::workflow::input_digest(self.simulation.state()), + request_sha256: crate::workflow::request_digest(inputs), + force_count: inputs.forces.len(), + smoke_source_present: inputs.smoke_source.is_some(), + volume_source_present: inputs.source.is_some(), + run_id: self.identity.run_id, + attempt_id, + accepted_generation, + attempted_generation: generation, interval_start, - history: self.pressure_history, - dynamic_viscosity: viscosity.as_ref().map_or(0.0, |(_, mu)| *mu), - provider: preferred, - }; - let mut expired = false; - let result = self.simulation.step_with_configured_pressure( - inputs, - workspace, - viscosity, - configured, - |stage| { - expired = deadline.is_some_and(|d| now() >= d); + requested_dt: inputs.requested_dt, + actual_dt: None, + publication: None, + outcome: AttemptOutcome::Failed, + nodes: Vec::with_capacity(NodeKind::ALL.len() + 2), + pressure: None, + actual_divergence_max: None, + diagnostic: String::new(), + })); + let result = (|| { + if self.stopped { + return Err(SessionError::Stopped); + } + if self.steps >= self.max_steps { + return Err(SessionError::StepLimit); + } + if deadline.is_some_and(|d| now() >= d) { + return Err(SessionError::Deadline); + } + trace.borrow_mut().nodes.extend([ + NodeReceipt { + node: NodeKind::Geometry, + status: NodeStatus::Completed, + }, + NodeReceipt { + node: NodeKind::AcceptedState, + status: NodeStatus::Completed, + }, + ]); + let configured = AcceptedContextInput { + workflow: trace.clone(), + run_id: self.identity.run_id.to_string(), + generation, + interval_start, + history: self.pressure_history, + dynamic_viscosity: viscosity.as_ref().map_or(0.0, |(_, mu)| *mu), + provider: preferred, + }; + let mut expired = false; + let result = self.simulation.step_with_configured_pressure( + inputs, + workspace, + viscosity, + configured, + |stage| { + expired = deadline.is_some_and(|d| now() >= d); + if expired { + return true; + } + let cancelled = cancel(stage); + expired = deadline.is_some_and(|d| now() >= d); + expired || cancelled + }, + ); + let (report, provider) = result.map_err(|e| { if expired { - return true; + SessionError::Deadline + } else { + SessionError::Step(e) } - let cancelled = cancel(stage); - expired = deadline.is_some_and(|d| now() >= d); - expired || cancelled - }, - ); - let (report, provider) = result.map_err(|e| { - if expired { - SessionError::Deadline - } else { - SessionError::Step(e) + })?; + // In-memory bounded receipt adoption follows the existing atomic commit. + self.accept_pressure_history(report, &provider.pressure, provider.dynamic_viscosity); + self.interval_start = Some(interval_start); + self.steps += 1; + self.report = Some(report); + self.provider = Some(provider.pressure.clone()); + self.configured = Some(provider.clone()); + Ok((report, provider)) + })(); + *evidence = Some(match &result { + Ok((_, provider)) => provider + .workflow + .as_ref() + .expect("workflow prepared before commit") + .clone(), + Err(error) => { + let mut trace = trace.borrow_mut(); + let diagnostic = format!("{error}"); + trace.diagnostic = diagnostic.chars().take(240).collect(); + trace.outcome = match error { + SessionError::Deadline + | SessionError::Step(crate::LiquidStepError::Cancelled) + | SessionError::Step(crate::LiquidStepError::Carrier( + crate::SimulationError::Cancelled { .. }, + )) + | SessionError::Step(crate::LiquidStepError::Carrier( + crate::SimulationError::Pressure(crate::PressureError::Cancelled { + .. + }), + )) + | SessionError::Step(crate::LiquidStepError::Carrier( + crate::SimulationError::Viscosity(crate::ViscosityError::Cancelled { + .. + }), + )) + | SessionError::Step(crate::LiquidStepError::Volume( + crate::LiquidVolumeError::Cancelled { .. }, + )) => AttemptOutcome::Cancelled, + SessionError::Stopped + | SessionError::StepLimit + | SessionError::PressureBinding(_) + | SessionError::Step(crate::LiquidStepError::Carrier( + crate::SimulationError::UnsupportedProviderDomain + | crate::SimulationError::ProviderWorkspaceMismatch + | crate::SimulationError::InvalidTimeStep, + )) + | SessionError::Step(crate::LiquidStepError::Carrier( + crate::SimulationError::Viscosity(crate::ViscosityError::UnsupportedDomain), + )) => AttemptOutcome::Refused, + _ => AttemptOutcome::Failed, + }; + std::sync::Arc::new(trace.clone()) } - })?; - // All fallible/allocating work and callbacks ended before the physics - // commit. Arc receipt clones are bounded ownership updates, not arrays. - self.accept_pressure_history(report, &provider.pressure, provider.dynamic_viscosity); - self.interval_start = Some(interval_start); - self.steps += 1; - self.report = Some(report); - self.provider = Some(provider.pressure.clone()); - self.configured = Some(provider.clone()); - Ok((report, provider)) + }); + result } pub(super) fn accept_pressure_history( &mut self, diff --git a/src/simulation.rs b/src/simulation.rs index 2db572d5..63fcec32 100644 --- a/src/simulation.rs +++ b/src/simulation.rs @@ -141,6 +141,7 @@ struct BoundaryStepInput<'a> { boundary: crate::BoxFluxStepBoundary, } struct StepDomains<'a, 'p> { + workflow: Option<&'a crate::workflow::Capture>, emitter: Option, barrier: Option<&'a crate::TriangleSurface>, boundary: Option>, @@ -401,6 +402,7 @@ impl Simulation { None, &[], StepDomains { + workflow: None, emitter: Some(emitter), barrier: None, boundary: None, @@ -482,6 +484,7 @@ impl Simulation { source, forces, StepDomains { + workflow: None, emitter: None, barrier: None, boundary: None, @@ -518,6 +521,7 @@ impl Simulation { source, forces, StepDomains { + workflow: None, emitter: None, barrier, boundary, @@ -540,6 +544,7 @@ impl Simulation { surface: Option>, viscosity: Option<(&mut crate::ViscosityWorkspace, f64)>, provider: Option>, + workflow: Option<&crate::workflow::Capture>, cancel: impl FnMut(StepStage) -> bool, ) -> Result { self.prepare_impl( @@ -547,6 +552,7 @@ impl Simulation { source, forces, StepDomains { + workflow, emitter: None, barrier: None, surface, @@ -560,6 +566,21 @@ impl Simulation { cancel, ) } + pub(crate) fn workflow_descriptor( + &self, + viscosity: Option, + ) -> crate::workflow::WorkflowDescriptor { + crate::workflow::WorkflowDescriptor { + version: crate::workflow::DESCRIPTOR_VERSION, + grid: self.grid.clone(), + density: self.config.density, + viscosity, + implementation: self.implementation, + pressure: self.config.pressure, + actual_divergence_limit: self.config.actual_divergence_limit, + max_courant: self.config.max_courant, + } + } pub(crate) fn density(&self) -> f64 { self.config.density } @@ -662,6 +683,7 @@ impl Simulation { ) -> Result { self.inspection_pressure = None; let StepDomains { + workflow, emitter, barrier, mut boundary, @@ -788,6 +810,14 @@ impl Simulation { if dt <= 0.0 || !next_time.is_finite() || next_time <= self.time { return Err(SimulationError::TimeResolution); } + if let Some(trace) = workflow { + trace.borrow_mut().actual_dt = Some(dt); + } + crate::workflow::complete( + workflow, + crate::workflow::NodeKind::Interval, + crate::workflow::NodeStatus::Completed, + ); checkpoint(&mut cancel, StepStage::BeforeAdvection)?; advect_velocity( &self.grid, @@ -796,6 +826,11 @@ impl Simulation { self.candidate.velocity_mut(), || cancel(StepStage::VelocitySlice), )?; + crate::workflow::complete( + workflow, + crate::workflow::NodeKind::Advection, + crate::workflow::NodeStatus::Completed, + ); if let Some(surface) = surface { match surface { crate::column_surface::PressureSurface::Slab(s) => { @@ -862,6 +897,11 @@ impl Simulation { )? }) }; + crate::workflow::complete( + workflow, + crate::workflow::NodeKind::Forces, + crate::workflow::NodeStatus::Completed, + ); let viscosity = if let Some((workspace, mu)) = viscosity { let report = workspace .prepare( @@ -873,6 +913,11 @@ impl Simulation { ) .map_err(SimulationError::Viscosity)?; workspace.publish(self.candidate.velocity_mut()); + crate::workflow::complete( + workflow, + crate::workflow::NodeKind::Viscosity, + crate::workflow::NodeStatus::Completed, + ); Some(report) } else { None @@ -929,8 +974,10 @@ impl Simulation { dt, self.config.pressure, input.preferred.take(), + workflow, || cancel(StepStage::PressureIteration), )?; + crate::workflow::pressure_completed(workflow, &report); let pressure = report.accepted; operator.correct_candidate_in_place( input.workspace.pressure(), @@ -965,6 +1012,14 @@ impl Simulation { limit: self.config.actual_divergence_limit, }); } + if let Some(trace) = workflow { + trace.borrow_mut().actual_divergence_max = Some(actual_divergence_max); + } + crate::workflow::complete( + workflow, + crate::workflow::NodeKind::Projection, + crate::workflow::NodeStatus::Completed, + ); (pressure, actual_divergence_max, None) }; if let (Some(work), Some(report)) = (&mut boundary_work, projection) { @@ -1077,6 +1132,11 @@ impl Simulation { if !tracer_integral.is_finite() || !kinetic_energy.is_finite() { return Err(SimulationError::ArithmeticFailure); } + crate::workflow::complete( + workflow, + crate::workflow::NodeKind::TracerTransport, + crate::workflow::NodeStatus::Completed, + ); checkpoint(&mut cancel, StepStage::BeforeCommit)?; let legacy = crate::BarrierStepReport { step: ForcedStepReport { diff --git a/src/solver_provider.rs b/src/solver_provider.rs index d53e6c49..69dc3536 100644 --- a/src/solver_provider.rs +++ b/src/solver_provider.rs @@ -148,11 +148,21 @@ impl ProviderWorkspace { self.reference.allocated_bytes() + (self.pressure.capacity() + self.product.capacity()) * 8 } pub fn solve( + &mut self, + request: PressureRequest<'_>, + settings: PressureSettings, + preferred: Option<&mut dyn PressureProvider>, + cancelled: impl FnMut() -> bool, + ) -> Result { + self.solve_observed(request, settings, preferred, cancelled, None) + } + pub(crate) fn solve_observed( &mut self, request: PressureRequest<'_>, settings: PressureSettings, preferred: Option<&mut dyn PressureProvider>, mut cancelled: impl FnMut() -> bool, + workflow: Option<&crate::workflow::Capture>, ) -> Result { let start = Instant::now(); if request.grid != &self.grid { @@ -181,6 +191,11 @@ impl ProviderWorkspace { if !sum.is_finite() || !sum_abs.is_finite() || !norm.is_finite() || !threshold.is_finite() { return Err(PressureError::Breakdown { iteration: 0 }); } + if let Some(trace) = workflow + && let Some(p) = trace.borrow_mut().pressure.as_mut() + { + p.residual_l2_limit = Some(threshold); + } let rounding_budget = 64.0 * f64::EPSILON * sum_abs; if sum.abs() > rounding_budget { return Err(PressureError::IncompatibleRhs { @@ -253,6 +268,21 @@ impl ProviderWorkspace { Ok(candidate) }); report.qualification = begin.elapsed(); + if let Some(trace) = workflow { + if let Some(p) = trace.borrow_mut().pressure.as_mut() { + p.candidate = report.candidate; + p.rejection = qualified.as_ref().err().map(|r| format!("{r:?}")); + } + crate::workflow::complete( + Some(trace), + crate::workflow::NodeKind::PressureQualification, + if qualified.is_ok() { + crate::workflow::NodeStatus::Accepted + } else { + crate::workflow::NodeStatus::Rejected + }, + ); + } if cancelled() { return Err(PressureError::Cancelled { iterations: 0 }); } @@ -266,6 +296,16 @@ impl ProviderWorkspace { Err(reason) => report.rejection = Some(reason), } } + if let Some(trace) = workflow { + if let Some(p) = trace.borrow_mut().pressure.as_mut() { + p.selected = self.fallback.id().into(); + } + crate::workflow::complete( + Some(trace), + crate::workflow::NodeKind::NativeFallback, + crate::workflow::NodeStatus::Started, + ); + } let begin = Instant::now(); report.accepted = self.reference.solve_rhs( &operator, @@ -275,6 +315,11 @@ impl ProviderWorkspace { &mut cancelled, )?; self.pressure.copy_from_slice(self.reference.pressure()); + crate::workflow::complete( + workflow, + crate::workflow::NodeKind::NativeFallback, + crate::workflow::NodeStatus::Selected, + ); report.fallback = begin.elapsed(); report.total = start.elapsed(); Ok(report) diff --git a/src/solver_provider/configured.rs b/src/solver_provider/configured.rs index 0957b491..70b66f8d 100644 --- a/src/solver_provider/configured.rs +++ b/src/solver_provider/configured.rs @@ -84,6 +84,8 @@ pub struct AcceptedPressureContextStamp { /// Only returned/published with the complete accepted transport interval. #[derive(Debug, Clone)] pub struct ConfiguredProviderReport { + /// Actual composed execution evidence; absent only for historical receipts. + pub workflow: Option>, pub binding: Option>, pub context: Option, pub dynamic_viscosity: f64, @@ -102,6 +104,7 @@ pub(crate) struct AcceptedHistoryStamp { pub all_native: bool, } pub(crate) struct AcceptedContextInput<'a> { + pub workflow: std::rc::Rc, pub run_id: String, pub generation: u64, pub interval_start: f64, @@ -207,29 +210,44 @@ impl PressureProvider for ContextAdapter<'_, '_> { } } fn supports(&self, request: &PressureRequest<'_>) -> Result<(), ProviderRejection> { - self.binding - .as_ref() - .ok_or(ProviderRejection::Unsupported( - "invalid configured provider identity", - ))? - .validate()?; - let context = self.request(request)?; - self.stamp.set(Some(AcceptedPressureContextStamp { - generation: context.context.generation(), - interval_start: context.context.interval_start(), - interval_end: context.context.interval_end(), - dt: context.context.dt(), - native_prefix_steps: context.native_prefix_steps, - prior_generation: context.context.prior().map(|p| p.generation), - prior_interval_end: context.context.prior().map(|p| p.interval_end), - prior_pressure_sha256: context.context.prior().map(|p| hash_values(p.values)), - rhs_sha256: hash_values(context.context.rhs()), - })); - self.input - .provider - .as_ref() - .ok_or(ProviderRejection::Unsupported("no configured provider"))? - .supports(&context) + let result = (|| { + self.binding + .as_ref() + .ok_or(ProviderRejection::Unsupported( + "invalid configured provider identity", + ))? + .validate()?; + let context = self.request(request)?; + self.stamp.set(Some(AcceptedPressureContextStamp { + generation: context.context.generation(), + interval_start: context.context.interval_start(), + interval_end: context.context.interval_end(), + dt: context.context.dt(), + native_prefix_steps: context.native_prefix_steps, + prior_generation: context.context.prior().map(|p| p.generation), + prior_interval_end: context.context.prior().map(|p| p.interval_end), + prior_pressure_sha256: context.context.prior().map(|p| hash_values(p.values)), + rhs_sha256: hash_values(context.context.rhs()), + })); + self.input + .provider + .as_ref() + .ok_or(ProviderRejection::Unsupported("no configured provider"))? + .supports(&context) + })(); + if let Some(p) = self.input.workflow.borrow_mut().pressure.as_mut() { + p.binding = self.binding.as_deref().cloned(); + p.support = Some(result.is_ok()); + p.rejection = result.as_ref().err().map(|r| format!("{r:?}")); + } + if result.is_err() { + crate::workflow::complete( + Some(self.input.workflow.as_ref()), + crate::workflow::NodeKind::PressureCandidate, + crate::workflow::NodeStatus::Rejected, + ); + } + result } fn predict( &mut self, @@ -238,6 +256,9 @@ impl PressureProvider for ContextAdapter<'_, '_> { cancelled: &mut dyn FnMut() -> bool, ) -> Result<(), ProviderRejection> { self.prediction_called = true; + if let Some(p) = self.input.workflow.borrow_mut().pressure.as_mut() { + p.prediction_called = true; + } let provider = self .input .provider @@ -247,6 +268,20 @@ impl PressureProvider for ContextAdapter<'_, '_> { .request(request) .and_then(|context| provider.predict(&context, pressure, cancelled)); self.input.provider = Some(provider); + crate::workflow::complete( + Some(self.input.workflow.as_ref()), + crate::workflow::NodeKind::PressureCandidate, + if result.is_ok() { + crate::workflow::NodeStatus::CandidateReturned + } else { + crate::workflow::NodeStatus::Rejected + }, + ); + if let Err(e) = &result + && let Some(p) = self.input.workflow.borrow_mut().pressure.as_mut() + { + p.rejection = Some(format!("{e:?}")); + } result } } diff --git a/src/workflow.rs b/src/workflow.rs new file mode 100644 index 00000000..d42588df --- /dev/null +++ b/src/workflow.rs @@ -0,0 +1,847 @@ +//! Bounded observations of the existing sealed MAC composition. +//! This describes the native method; it is not an executable graph or a claim +//! of continuum accuracy. Receipts are emitted by the numerical owners. +use crate::solver_provider::{ProviderReport, configured::ContextProviderIdentity}; +use crate::{GridGeometry, PressureImplementation, PressureReport, PressureSettings}; +use sha2::{Digest, Sha256}; +use std::{cell::RefCell, sync::Arc}; + +pub const DESCRIPTOR_VERSION: u32 = 1; +pub const MAX_NODES: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum NodeKind { + Geometry, + AcceptedState, + Interval, + Advection, + Forces, + Viscosity, + PressureRequest, + PressureCandidate, + PressureQualification, + NativeFallback, + Projection, + TracerTransport, + VolumeTransport, + Publication, +} +impl NodeKind { + pub const ALL: [Self; 14] = [ + Self::Geometry, + Self::AcceptedState, + Self::Interval, + Self::Advection, + Self::Forces, + Self::Viscosity, + Self::PressureRequest, + Self::PressureCandidate, + Self::PressureQualification, + Self::NativeFallback, + Self::Projection, + Self::TracerTransport, + Self::VolumeTransport, + Self::Publication, + ]; + pub fn label(self) -> &'static str { + match self { + Self::Geometry => "Sealed Cartesian geometry", + Self::AcceptedState => "Accepted native MAC state", + Self::Interval => "Admit actual interval", + Self::Advection => "Advect face velocity", + Self::Forces => "Apply prescribed body forces", + Self::Viscosity => "Newtonian viscosity", + Self::PressureRequest => "Assemble actual pressure request", + Self::PressureCandidate => "Configured pressure candidate", + Self::PressureQualification => "Host pressure qualification", + Self::NativeFallback => "Native pressure / fallback", + Self::Projection => "Project and qualify corrected velocity", + Self::TracerTransport => "Transport appearance tracer", + Self::VolumeTransport => "Transport represented liquid volume", + Self::Publication => "Atomic Session publication", + } + } + pub fn lane(self) -> usize { + match self { + Self::Geometry => 0, + Self::AcceptedState => 1, + Self::PressureQualification | Self::Projection => 3, + Self::Publication => 4, + _ => 2, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PortKind { + Geometry, + NativeState, + Interval, + FaceVelocity, + CellPressure, + CellFraction, + CellTracer, + IntegratedRhs, + Qualification, +} +impl PortKind { + pub fn units(self) -> &'static str { + match self { + Self::Geometry => "m", + Self::NativeState => "native MAC fields", + Self::Interval => "s", + Self::FaceVelocity => "m/s", + Self::CellPressure => "Pa", + Self::CellFraction | Self::CellTracer => "1", + Self::IntegratedRhs => "m3/s2", + Self::Qualification => "host gate", + } + } + pub fn association(self) -> &'static str { + match self { + Self::FaceVelocity => "staggered Cartesian faces; f32; x-fast", + Self::CellPressure => "cell centre; f64; x-fast; cell0 gauge=0", + Self::CellFraction => "cell centre; f64; x-fast", + Self::CellTracer => "cell centre; f32; x-fast; appearance only", + Self::IntegratedRhs => "cell centre; f64; x-fast; integrated full operator", + _ => "composition metadata", + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeKind { + Data, + Fallback, + TimeLoop, + IterationLoop, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkflowEdge { + pub from: NodeKind, + pub to: NodeKind, + pub port: PortKind, + pub kind: EdgeKind, +} + +/// Only the already-supported sealed, constant-density method is representable. +#[derive(Debug, Clone)] +pub struct WorkflowDescriptor { + pub version: u32, + pub grid: GridGeometry, + pub density: f64, + /// None means disabled; Some(0) is the actually applied zero stage. + pub viscosity: Option, + pub implementation: PressureImplementation, + pub pressure: PressureSettings, + pub actual_divergence_limit: f64, + pub max_courant: f64, +} +impl WorkflowDescriptor { + pub fn family(&self) -> &'static str { + "sealed-constant-density-mac-v1" + } + pub fn operator(&self) -> &'static str { + "integrated-sealed-mac-pressure-v1; cell0 gauge=0" + } + pub fn nodes(&self) -> &'static [NodeKind] { + &NodeKind::ALL + } + pub fn edges(&self) -> Vec { + use EdgeKind::*; + use NodeKind::*; + use PortKind::{ + CellFraction, CellPressure, CellTracer, FaceVelocity, IntegratedRhs, NativeState, + Qualification, + }; + let velocity = if self.viscosity.is_some() { + Viscosity + } else { + Forces + }; + let mut edges = vec![ + WorkflowEdge { + from: AcceptedState, + to: Interval, + port: NativeState, + kind: Data, + }, + WorkflowEdge { + from: AcceptedState, + to: Advection, + port: FaceVelocity, + kind: Data, + }, + WorkflowEdge { + from: Advection, + to: Forces, + port: FaceVelocity, + kind: Data, + }, + WorkflowEdge { + from: velocity, + to: PressureRequest, + port: FaceVelocity, + kind: Data, + }, + WorkflowEdge { + from: PressureRequest, + to: PressureCandidate, + port: IntegratedRhs, + kind: Data, + }, + WorkflowEdge { + from: PressureRequest, + to: PressureQualification, + port: IntegratedRhs, + kind: Data, + }, + WorkflowEdge { + from: PressureCandidate, + to: PressureQualification, + port: CellPressure, + kind: Data, + }, + WorkflowEdge { + from: PressureQualification, + to: Projection, + port: CellPressure, + kind: Data, + }, + WorkflowEdge { + from: PressureQualification, + to: NativeFallback, + port: Qualification, + kind: Fallback, + }, + WorkflowEdge { + from: PressureRequest, + to: NativeFallback, + port: IntegratedRhs, + kind: Data, + }, + WorkflowEdge { + from: NativeFallback, + to: Projection, + port: CellPressure, + kind: Data, + }, + WorkflowEdge { + from: velocity, + to: Projection, + port: FaceVelocity, + kind: Data, + }, + WorkflowEdge { + from: Projection, + to: TracerTransport, + port: FaceVelocity, + kind: Data, + }, + WorkflowEdge { + from: Projection, + to: VolumeTransport, + port: FaceVelocity, + kind: Data, + }, + WorkflowEdge { + from: AcceptedState, + to: TracerTransport, + port: CellTracer, + kind: Data, + }, + WorkflowEdge { + from: AcceptedState, + to: VolumeTransport, + port: CellFraction, + kind: Data, + }, + WorkflowEdge { + from: Projection, + to: Publication, + port: NativeState, + kind: Data, + }, + WorkflowEdge { + from: TracerTransport, + to: Publication, + port: CellTracer, + kind: Data, + }, + WorkflowEdge { + from: VolumeTransport, + to: Publication, + port: CellFraction, + kind: Data, + }, + WorkflowEdge { + from: NativeFallback, + to: NativeFallback, + port: CellPressure, + kind: IterationLoop, + }, + WorkflowEdge { + from: Publication, + to: AcceptedState, + port: NativeState, + kind: TimeLoop, + }, + ]; + if self.viscosity.is_some() { + edges.push(WorkflowEdge { + from: Forces, + to: Viscosity, + port: FaceVelocity, + kind: Data, + }); + } + for to in [ + Advection, + Forces, + Viscosity, + PressureRequest, + PressureCandidate, + PressureQualification, + NativeFallback, + Projection, + TracerTransport, + VolumeTransport, + ] { + edges.push(WorkflowEdge { + from: NodeKind::Geometry, + to, + port: PortKind::Geometry, + kind: Data, + }); + edges.push(WorkflowEdge { + from: Interval, + to, + port: PortKind::Interval, + kind: Data, + }); + } + edges + } + pub fn validate(&self) -> Result<(), &'static str> { + if self.version != DESCRIPTOR_VERSION { + return Err("unsupported workflow descriptor version"); + } + if !self.density.is_finite() + || self.density <= 0.0 + || self + .viscosity + .is_some_and(|v| !v.is_finite() || v < 0.0 || (v != 0.0 && !v.is_normal())) + || [ + self.pressure.relative_residual, + self.pressure.absolute_residual, + self.pressure.divergence_limit, + self.actual_divergence_limit, + self.max_courant, + ] + .iter() + .any(|v| !v.is_finite() || *v < 0.0) + || self.max_courant <= 0.0 + || self.max_courant > 1.0 + { + return Err("invalid workflow descriptor parameters"); + } + Ok(()) + } + /// Canonical fixed ordering, exact IEEE parameter bits, no labels/UI layout. + pub fn digest(&self) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"rheon-sealed-mac-workflow-v1\0"); + h.update(self.version.to_le_bytes()); + for n in self.grid.counts() { + h.update((n as u64).to_le_bytes()); + } + for v in self + .grid + .spacing() + .into_iter() + .chain(self.grid.origin()) + .chain([ + self.density, + self.viscosity.unwrap_or(0.0), + self.pressure.relative_residual, + self.pressure.absolute_residual, + self.pressure.divergence_limit, + self.actual_divergence_limit, + self.max_courant, + ]) + { + h.update(v.to_bits().to_le_bytes()); + } + h.update([u8::from(self.viscosity.is_some())]); + h.update(self.implementation.id().as_bytes()); + h.update((self.pressure.max_iterations as u64).to_le_bytes()); + for node in self.nodes() { + h.update([*node as u8]); + } + for edge in self.edges() { + h.update([ + edge.from as u8, + edge.to as u8, + edge.port as u8, + edge.kind as u8, + ]); + } + h.finalize().into() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeStatus { + Started, + Completed, + CandidateReturned, + Rejected, + Selected, + Accepted, +} +#[derive(Debug, Clone)] +pub struct NodeReceipt { + pub node: NodeKind, + pub status: NodeStatus, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttemptOutcome { + Accepted, + Cancelled, + Failed, + Refused, +} +#[derive(Debug, Clone)] +pub struct PressureFacts { + pub selected: String, + pub binding: Option, + pub prediction_called: bool, + pub support: Option, + pub rejection: Option, + pub rhs_sha256: Option<[u8; 32]>, + pub candidate: Option, + pub accepted: Option, + /// Pressure-call scope only; never presented as whole workflow time. + pub pressure_call_ns: Option, + /// Actual max(absolute, relative * ||rhs||₂), m³/s², from the host dispatcher. + pub residual_l2_limit: Option, +} +#[derive(Debug, Clone)] +pub struct WorkflowAttempt { + pub descriptor: WorkflowDescriptor, + pub descriptor_sha256: [u8; 32], + /// Exact accepted native state consumed by this attempt, not a model-input claim. + pub input_sha256: [u8; 32], + /// Actual control values, including complete source rates and force recipe. + pub request_sha256: [u8; 32], + pub force_count: usize, + pub smoke_source_present: bool, + pub volume_source_present: bool, + pub run_id: u64, + pub attempt_id: u64, + pub accepted_generation: u64, + pub attempted_generation: u64, + pub interval_start: f64, + pub requested_dt: f64, + pub actual_dt: Option, + pub publication: Option, + pub outcome: AttemptOutcome, + pub nodes: Vec, + pub pressure: Option, + pub actual_divergence_max: Option, + pub diagnostic: String, +} +impl WorkflowAttempt { + /// Compare retained actual-call identity with a reconstructed declared recipe. + /// A match binds controls; it does not qualify their physical accuracy. + pub fn matches_request(&self, inputs: crate::LiquidStepInputs<'_>) -> bool { + self.request_sha256 == request_digest(inputs) + && self.force_count == inputs.forces.len() + && self.smoke_source_present == inputs.smoke_source.is_some() + && self.volume_source_present == inputs.source.is_some() + } + pub fn validate(&self) -> Result<(), &'static str> { + self.descriptor.validate()?; + if self.descriptor_sha256 != self.descriptor.digest() { + return Err("workflow descriptor digest"); + } + if self.attempt_id == 0 + || self.accepted_generation.checked_add(1) != Some(self.attempted_generation) + || !self.interval_start.is_finite() + || self.interval_start < 0.0 + || !self.requested_dt.is_finite() + || self.requested_dt <= 0.0 + || self.actual_dt.is_some_and(|dt| { + !dt.is_finite() + || dt <= 0.0 + || dt > self.requested_dt + || !(self.interval_start + dt).is_finite() + || self.interval_start + dt <= self.interval_start + }) + || self.nodes.len() > MAX_NODES + || self.diagnostic.len() > 1000 + || self + .actual_divergence_max + .is_some_and(|v| !v.is_finite() || v < 0.0) + { + return Err("workflow attempt association"); + } + if (self.outcome == AttemptOutcome::Accepted) != self.publication.is_some() + || self + .publication + .is_some_and(|g| g != self.attempted_generation || self.actual_dt.is_none()) + || (self.publication.is_some() + && self.nodes.last().is_none_or(|n| { + n.node != NodeKind::Publication || n.status != NodeStatus::Accepted + })) + { + return Err("workflow publication association"); + } + if let Some(p) = &self.pressure + && (p.selected.len() > 1000 + || p.rejection.as_ref().is_some_and(|s| s.len() > 1000) + || p.binding.as_ref().is_some_and(|b| b.validate().is_err()) + || p.residual_l2_limit + .is_some_and(|v| !v.is_finite() || v < 0.0) + || (p.prediction_called && p.support != Some(true)) + || (p.accepted.is_some() && (p.selected.is_empty() || self.actual_dt.is_none())) + || p.accepted.is_some_and(|r| { + [ + r.true_residual_l2, + r.true_residual_max, + r.predicted_divergence_max, + ] + .iter() + .any(|v| !v.is_finite() || *v < 0.0) + })) + { + return Err("workflow pressure facts"); + } + self.validate_nodes() + } + fn validate_nodes(&self) -> Result<(), &'static str> { + use NodeKind::*; + use NodeStatus::*; + let mut expected = vec![ + (Geometry, Completed), + (AcceptedState, Completed), + (Interval, Completed), + (Advection, Completed), + (Forces, Completed), + ]; + if self.descriptor.viscosity.is_some() { + expected.push((Viscosity, Completed)); + } + expected.push((PressureRequest, Completed)); + let base = expected.len(); + if self.pressure.is_some() && self.nodes.len() < base { + return Err("workflow pressure stage association"); + } + let mut fallback = true; + if let Some(p) = &self.pressure { + if let Some(supported) = p.support { + let candidate = self.nodes.get(base).map_or(CandidateReturned, |n| n.status); + if !matches!(candidate, CandidateReturned | Rejected) + || (!supported && candidate != Rejected) + { + return Err("workflow candidate stage status"); + } + expected.push((PressureCandidate, candidate)); + fallback = p.rejection.is_some(); + expected.push(( + PressureQualification, + if fallback { Rejected } else { Accepted }, + )); + } else if p.binding.is_some() || p.prediction_called || p.rejection.is_some() { + return Err("workflow native pressure facts"); + } + } + if fallback { + expected.extend([(NativeFallback, Started), (NativeFallback, Selected)]); + } + expected.extend([ + (Projection, Completed), + (TracerTransport, Completed), + (VolumeTransport, Completed), + (Publication, Accepted), + ]); + if self.nodes.len() > expected.len() + || self + .nodes + .iter() + .zip(&expected) + .any(|(n, (node, status))| n.node != *node || n.status != *status) + || (self.outcome == AttemptOutcome::Accepted && self.nodes.len() != expected.len()) + || (self.outcome != AttemptOutcome::Accepted + && self.nodes.iter().any(|n| n.node == Publication)) + || (self.nodes.iter().any(|n| n.node == Interval) != self.actual_dt.is_some()) + || (self.nodes.iter().any(|n| n.node == Projection) + != self.actual_divergence_max.is_some()) + { + return Err("workflow execution ordering"); + } + if self.nodes.iter().any(|n| { + matches!( + n.node, + PressureRequest + | PressureCandidate + | PressureQualification + | NativeFallback + | Projection + ) + }) && self + .pressure + .as_ref() + .is_none_or(|p| p.rhs_sha256.is_none()) + { + return Err("workflow pressure request association"); + } + if let Some(p) = &self.pressure { + let qualification = self + .nodes + .iter() + .find(|n| n.node == PressureQualification) + .map(|n| n.status); + let native_selected = self + .nodes + .iter() + .any(|n| n.node == NativeFallback && n.status == Selected); + let within_limits = |r: PressureReport| { + [ + r.true_residual_l2, + r.true_residual_max, + r.predicted_divergence_max, + ] + .iter() + .all(|v| v.is_finite() && *v >= 0.0) + && r.predicted_divergence_max <= self.descriptor.pressure.divergence_limit + && p.residual_l2_limit + .is_some_and(|limit| r.true_residual_l2 <= limit) + }; + if p.accepted.is_some_and(|r| !within_limits(r)) + || (qualification == Some(Accepted) + && p.candidate.is_none_or(|r| !within_limits(r))) + || self + .actual_divergence_max + .is_some_and(|v| v > self.descriptor.actual_divergence_limit) + { + return Err("workflow observed qualification limits"); + } + if (p.candidate.is_some() && qualification.is_none()) + || (qualification == Some(Accepted) && p.candidate.is_none()) + || (p.accepted.is_some() && qualification != Some(Accepted) && !native_selected) + || (p.pressure_call_ns.is_some() && p.accepted.is_none()) + || (native_selected && p.accepted.is_none()) + { + return Err("workflow pressure diagnostic stage association"); + } + } + if let Some(p) = &self.pressure + && ((p.support == Some(true) && (p.binding.is_none() || !p.prediction_called)) + || (p.support == Some(false) && (p.prediction_called || p.rejection.is_none())) + || (self + .nodes + .iter() + .any(|n| n.node == PressureCandidate && n.status == CandidateReturned) + && !p.prediction_called) + || (self.nodes.iter().any(|n| n.node == NativeFallback) + && p.selected != self.descriptor.implementation.id())) + { + return Err("workflow provider branch association"); + } + if self.outcome == AttemptOutcome::Accepted { + let p = self + .pressure + .as_ref() + .ok_or("workflow accepted pressure absent")?; + let selected = if fallback { + self.descriptor.implementation.id() + } else { + "configured-pressure-context-v1" + }; + if p.accepted.is_none() + || p.selected != selected + || p.rhs_sha256.is_none() + || p.residual_l2_limit.is_none() + || self.actual_divergence_max.is_none() + { + return Err("workflow accepted pressure association"); + } + if !fallback { + let bits = |r: PressureReport| { + [ + r.iterations as u64, + r.true_residual_l2.to_bits(), + r.true_residual_max.to_bits(), + r.predicted_divergence_max.to_bits(), + ] + }; + if p.candidate.map(bits) != p.accepted.map(bits) { + return Err("workflow candidate acceptance association"); + } + } + } + Ok(()) + } +} + +/// Shared only within one synchronous numerical attempt. No callback creates +/// receipt facts; callbacks can stop the work whose owners emit those facts. +pub(crate) type Capture = RefCell; +pub(crate) fn complete(capture: Option<&Capture>, node: NodeKind, status: NodeStatus) { + if let Some(c) = capture { + let mut c = c.borrow_mut(); + // The finite composition emits fewer than MAX_NODES summaries; no loop + // iterations or per-cell data are appended here. + assert!(c.nodes.len() < MAX_NODES); + c.nodes.push(NodeReceipt { node, status }); + } +} +pub(crate) fn pressure_completed(capture: Option<&Capture>, report: &ProviderReport) { + if let Some(c) = capture { + let mut c = c.borrow_mut(); + let p = c.pressure.get_or_insert_with(|| PressureFacts { + selected: String::new(), + binding: None, + prediction_called: false, + support: None, + rejection: None, + rhs_sha256: None, + candidate: None, + accepted: None, + pressure_call_ns: None, + residual_l2_limit: None, + }); + p.selected = report.selected.into(); + p.rejection = report.rejection.as_ref().map(|r| format!("{r:?}")); + p.candidate = report.candidate; + p.accepted = Some(report.accepted); + p.pressure_call_ns = u64::try_from(report.total.as_nanos()).ok(); + drop(c); + } +} + +pub(crate) fn accepted(capture: &Capture) -> Arc { + let mut c = capture.borrow_mut(); + c.outcome = AttemptOutcome::Accepted; + c.publication = Some(c.attempted_generation); + c.nodes.push(NodeReceipt { + node: NodeKind::Publication, + status: NodeStatus::Accepted, + }); + Arc::new(c.clone()) +} + +/// Hash only the actual accepted native fields and identity. Provider request +/// RHS custody is separately observed at the pressure seam. No reconstruction. +pub(crate) fn input_digest(state: crate::LiquidTransportView<'_>) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"rheon-workflow-native-input-v1\0"); + h.update(state.carrier.generation.to_le_bytes()); + h.update(state.carrier.time.to_bits().to_le_bytes()); + for stamp in [state.carrier_stamp, state.liquid.stamp] { + h.update(stamp.id.to_le_bytes()); + h.update(stamp.version.to_le_bytes()); + } + h.update(state.liquid.time.to_bits().to_le_bytes()); + h.update(state.liquid.density.to_bits().to_le_bytes()); + for values in [ + state.carrier.x, + state.carrier.y, + state.carrier.z, + state.carrier.tracer, + ] { + h.update((values.len() as u64).to_le_bytes()); + for value in values { + h.update(value.to_bits().to_le_bytes()); + } + } + for values in [state.pressure, state.liquid.fraction] { + h.update((values.len() as u64).to_le_bytes()); + for value in values { + h.update(value.to_bits().to_le_bytes()); + } + } + h.finalize().into() +} + +#[cfg(test)] +mod tests; + +/// Canonical actual call controls, not a frozen-intent inference. +pub(crate) fn request_digest(inputs: crate::LiquidStepInputs<'_>) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"rheon-workflow-call-input-v1\0"); + h.update(inputs.requested_dt.to_bits().to_le_bytes()); + h.update((inputs.forces.len() as u64).to_le_bytes()); + for force in inputs.forces { + h.update([match force.units { + crate::ForceUnits::Acceleration => 0, + crate::ForceUnits::ForceDensity => 1, + }]); + for v in force.value { + h.update(v.to_bits().to_le_bytes()); + } + h.update([u8::from(force.region.is_some())]); + if let Some(r) = force.region { + for v in r.lower.into_iter().chain(r.upper) { + h.update(v.to_bits().to_le_bytes()); + } + } + } + h.update([u8::from(inputs.smoke_source.is_some())]); + if let Some(s) = inputs.smoke_source { + for v in s + .lower + .into_iter() + .chain(s.upper) + .chain([s.tracer_rate, s.vertical_acceleration]) + { + h.update(v.to_bits().to_le_bytes()); + } + } + let inlet = inputs.inlet; + h.update(inlet.stamp().id.to_le_bytes()); + h.update(inlet.stamp().version.to_le_bytes()); + for v in inlet.fractions().into_iter().flatten().chain([ + inputs.volume.max_outward_courant, + inputs.volume.actual_divergence_limit, + ]) { + h.update(v.to_bits().to_le_bytes()); + } + h.update([u8::from(inputs.source.is_some())]); + if let Some(source) = inputs.source { + let (stamp, rates) = source.workflow_parts(); + h.update(stamp.id.to_le_bytes()); + h.update(stamp.version.to_le_bytes()); + h.update((rates.len() as u64).to_le_bytes()); + for v in rates { + h.update(v.to_bits().to_le_bytes()); + } + } + h.finalize().into() +} +pub(crate) fn pressure_requested(capture: Option<&Capture>, rhs: &[f64]) { + if let Some(trace) = capture { + let mut h = Sha256::new(); + for v in rhs { + h.update(v.to_bits().to_le_bytes()); + } + trace.borrow_mut().pressure = Some(PressureFacts { + selected: String::new(), + binding: None, + prediction_called: false, + support: None, + rejection: None, + rhs_sha256: Some(h.finalize().into()), + candidate: None, + accepted: None, + pressure_call_ns: None, + residual_l2_limit: None, + }); + complete( + Some(trace), + NodeKind::PressureRequest, + NodeStatus::Completed, + ); + } +} diff --git a/src/workflow/tests.rs b/src/workflow/tests.rs new file mode 100644 index 00000000..bcb7e66c --- /dev/null +++ b/src/workflow/tests.rs @@ -0,0 +1,479 @@ +use super::*; +use crate::solver_provider::{ + ProviderRejection, + configured::{ContextPressureProvider, ContextPressureRequest}, +}; +use crate::*; +use std::sync::Arc; + +const CAP: usize = 16 * 1024 * 1024; +fn fixture(edge: usize, run_id: u64) -> (SimulationSession, ProviderStepWorkspace) { + fixture_fraction(edge, run_id, 1.0) +} +fn fixture_fraction( + edge: usize, + run_id: u64, + fraction: f64, +) -> (SimulationSession, ProviderStepWorkspace) { + let grid = GridGeometry::new([edge as u64; 3], [1.0 / edge as f64; 3], [0.0; 3]).unwrap(); + let config = LiquidTransportConfig { + carrier: SimulationConfig { + density: 998.2, + memory_limit: CAP, + pressure: 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, + }, + represented_density: 998.2, + volume_stamp: VolumeStamp { id: 1, version: 0 }, + carrier_id: 2, + }; + let workspace = + ProviderStepWorkspace::new(&grid, 998.2, CAP, PressureImplementation::JacobiPcgV1).unwrap(); + ( + SimulationSession::new( + SessionIdentity { + run_id, + source_revision: "workflow-fixture".into(), + }, + grid, + config, + PressureImplementation::JacobiPcgV1, + vec![fraction; edge.pow(3)], + CAP, + 1000, + ) + .unwrap(), + workspace, + ) +} +fn inputs(forces: &[BodyForce], dt: f64) -> LiquidStepInputs<'_> { + LiquidStepInputs { + requested_dt: dt, + smoke_source: None, + forces, + source: None, + inlet: LiquidInlet::new(VolumeStamp { id: 3, version: 0 }, [[0.0; 2]; 3]).unwrap(), + volume: LiquidVolumeSettings { + max_outward_courant: 1.0, + actual_divergence_limit: 1e-5, + }, + } +} +struct FixtureProvider { + reject_support: bool, + invalid_output: bool, +} +impl ContextPressureProvider for FixtureProvider { + fn identity(&self) -> Arc { + Arc::new(ContextProviderIdentity { + id: "classical-zero-fixture".into(), + version: "1".into(), + manifest_sha256: [3; 32], + checkpoint_sha256: None, + training_run_id: None, + runtime: "native-test".into(), + source_revision: "workflow-test".into(), + }) + } + fn supports(&self, _: &ContextPressureRequest<'_>) -> Result<(), ProviderRejection> { + if self.reject_support { + Err(ProviderRejection::Unsupported("fixture support refusal")) + } else { + Ok(()) + } + } + fn predict( + &mut self, + _: &ContextPressureRequest<'_>, + output: &mut [f64], + _: &mut dyn FnMut() -> bool, + ) -> Result<(), ProviderRejection> { + output.fill(if self.invalid_output { f64::NAN } else { 0.0 }); + Ok(()) + } +} + +#[test] +fn observed_candidate_fallback_and_applied_zero_are_real_owner_facts() { + for (reject_support, invalid_output) in [(false, false), (true, false), (false, true)] { + let (mut session, mut workspace) = fixture(3, 10); + let mut viscosity = + ViscosityWorkspace::new(session.snapshot().grid().clone(), CAP).unwrap(); + let mut provider = FixtureProvider { + reject_support, + invalid_output, + }; + let mut evidence = None; + let (_, report) = session + .step_with_configured_pressure_observed( + inputs(&[], 0.03125), + &mut workspace, + Some((&mut viscosity, 0.0)), + Some(&mut provider), + None, + |_| false, + 7, + &mut evidence, + ) + .unwrap(); + let evidence = evidence.unwrap(); + evidence.validate().unwrap(); + assert_eq!(evidence.run_id, 10); + assert_eq!(evidence.attempt_id, 7); + assert_eq!(evidence.accepted_generation, 0); + assert_eq!(evidence.publication, Some(1)); + assert_eq!(evidence.descriptor.viscosity, Some(0.0)); + let p = evidence.pressure.as_ref().unwrap(); + assert_eq!(p.binding.as_ref().unwrap().id, "classical-zero-fixture"); + assert_eq!(p.support, Some(!reject_support)); + assert_eq!(p.prediction_called, !reject_support); + assert_eq!(p.rejection.is_some(), reject_support || invalid_output); + assert_eq!(p.selected, report.pressure.selected); + assert!(p.rhs_sha256.is_some()); + assert_eq!( + evidence + .nodes + .iter() + .any(|n| n.node == NodeKind::NativeFallback), + reject_support || invalid_output + ); + } +} + +#[test] +fn cancellation_before_provider_after_return_and_before_commit_never_publishes() { + let stops = [ + LiquidStepStage::Carrier(StepStage::BeforeAdvection), + LiquidStepStage::Carrier(StepStage::BeforeProjectionAcceptance), + LiquidStepStage::BeforeCommit, + ]; + for (index, stop) in stops.into_iter().enumerate() { + let (mut session, mut workspace) = fixture(3, 11); + let before = input_digest(session.snapshot().state()); + let mut provider = FixtureProvider { + reject_support: false, + invalid_output: false, + }; + let mut evidence = None; + assert!( + session + .step_with_configured_pressure_observed( + inputs(&[], 0.02), + &mut workspace, + None, + Some(&mut provider), + None, + |stage| stage == stop, + index as u64 + 1, + &mut evidence + ) + .is_err() + ); + let evidence = evidence.unwrap(); + evidence.validate().unwrap(); + assert_eq!(evidence.outcome, AttemptOutcome::Cancelled); + assert_eq!(evidence.publication, None); + assert_eq!(evidence.accepted_generation, 0); + assert_eq!(evidence.attempted_generation, 1); + assert_eq!(evidence.input_sha256, before); + assert_eq!(input_digest(session.snapshot().state()), before); + assert!( + !evidence + .nodes + .iter() + .any(|n| n.node == NodeKind::Publication) + ); + if index == 0 { + assert!(evidence.pressure.is_none()); + } else { + assert!(evidence.pressure.as_ref().unwrap().accepted.is_some()); + } + } +} + +#[test] +fn actual_shortened_interval_and_native_trajectory_match_existing_pressure_path() { + let (mut observed, mut ow) = fixture_fraction(4, 12, 0.5); + let (mut baseline, mut bw) = fixture_fraction(4, 13, 0.5); + let forces = [BodyForce { + value: [2.0, 0.0, 0.0], + units: ForceUnits::Acceleration, + region: None, + }]; + let mut descriptor = None; + for index in 1..=4 { + let mut evidence = None; + let (report, _) = observed + .step_with_configured_pressure_observed( + inputs(&forces, 1.0), + &mut ow, + None, + None, + None, + |_| false, + index, + &mut evidence, + ) + .unwrap(); + baseline + .step_with_pressure_provider(inputs(&forces, 1.0), &mut bw, None, None, |_| false) + .unwrap(); + let evidence = evidence.unwrap(); + evidence.validate().unwrap(); + assert!(report.carrier.step.dt < 1.0); + assert_eq!( + evidence.actual_dt.unwrap().to_bits(), + report.carrier.step.dt.to_bits() + ); + assert_eq!( + input_digest(observed.snapshot().state()), + input_digest(baseline.snapshot().state()) + ); + if let Some(previous) = descriptor { + assert_eq!(evidence.descriptor_sha256, previous); + } + descriptor = Some(evidence.descriptor_sha256); + } +} + +#[test] +fn descriptor_digest_changes_only_with_actual_composition_and_invalid_binding_is_rejected() { + let (mut session, mut workspace) = fixture(3, 14); + let mut evidence = None; + session + .step_with_configured_pressure_observed( + inputs(&[], 0.01), + &mut workspace, + None, + None, + None, + |_| false, + 1, + &mut evidence, + ) + .unwrap(); + let evidence = evidence.unwrap(); + let mut changed = (*evidence).clone(); + changed.descriptor.viscosity = Some(0.0); + assert_ne!(changed.descriptor.digest(), evidence.descriptor_sha256); + assert_eq!(changed.validate(), Err("workflow descriptor digest")); + let mut changed = (*evidence).clone(); + changed.publication = Some(2); + assert_eq!(changed.validate(), Err("workflow publication association")); + let mut changed = (*evidence).clone(); + changed.nodes.resize( + MAX_NODES + 1, + NodeReceipt { + node: NodeKind::Geometry, + status: NodeStatus::Completed, + }, + ); + assert_eq!(changed.validate(), Err("workflow attempt association")); +} + +#[test] +fn in_stage_cancellation_and_invalid_attempt_id_preserve_exact_terminal_facts() { + let (mut session, mut workspace) = fixture(3, 21); + let mut evidence = None; + let result = session.step_with_configured_pressure_observed( + inputs(&[], 0.01), + &mut workspace, + None, + None, + None, + |stage| stage == LiquidStepStage::Carrier(StepStage::PressureIteration), + 1, + &mut evidence, + ); + assert!(matches!( + result, + Err(SessionError::Step(LiquidStepError::Carrier( + SimulationError::Pressure(PressureError::Cancelled { .. }) + ))) + )); + let trace = evidence.unwrap(); + trace.validate().unwrap(); + assert_eq!(trace.outcome, AttemptOutcome::Cancelled); + assert!(trace.pressure.as_ref().unwrap().rhs_sha256.is_some()); + let mut viscosity = ViscosityWorkspace::new(session.snapshot().grid().clone(), CAP).unwrap(); + let mut evidence = None; + assert!( + session + .step_with_configured_pressure_observed( + inputs(&[], 0.01), + &mut workspace, + Some((&mut viscosity, 0.001002)), + None, + None, + |stage| matches!(stage, LiquidStepStage::Carrier(StepStage::Viscosity(_))), + 2, + &mut evidence + ) + .is_err() + ); + let trace = evidence.unwrap(); + trace.validate().unwrap(); + assert_eq!(trace.outcome, AttemptOutcome::Cancelled); + let mut evidence = None; + assert!( + session + .step_with_configured_pressure_observed( + inputs(&[], 0.01), + &mut workspace, + None, + None, + None, + |_| false, + 0, + &mut evidence + ) + .is_err() + ); + assert!(evidence.is_none()); + assert_eq!(session.snapshot().state().carrier.generation, 0); +} + +#[test] +fn dispatch_cancellation_retains_candidate_return_qualification_and_fallback_start_separately() { + for stop in [2, 3, 4] { + let (mut session, mut workspace) = fixture(3, 22); + let mut provider = FixtureProvider { + reject_support: false, + invalid_output: true, + }; + let mut calls = 0; + let mut evidence = None; + assert!( + session + .step_with_configured_pressure_observed( + inputs(&[], 0.01), + &mut workspace, + None, + Some(&mut provider), + None, + |stage| { + if stage == LiquidStepStage::Carrier(StepStage::PressureIteration) { + calls += 1; + calls == stop + } else { + false + } + }, + 1, + &mut evidence + ) + .is_err() + ); + let trace = evidence.unwrap(); + trace.validate().unwrap(); + assert_eq!(trace.outcome, AttemptOutcome::Cancelled); + assert!( + trace + .nodes + .iter() + .any(|n| n.node == NodeKind::PressureCandidate + && n.status == NodeStatus::CandidateReturned) + ); + assert_eq!( + trace + .nodes + .iter() + .any(|n| n.node == NodeKind::PressureQualification), + stop >= 3 + ); + assert_eq!( + trace + .nodes + .iter() + .any(|n| n.node == NodeKind::NativeFallback && n.status == NodeStatus::Started), + stop >= 4 + ); + assert!( + !trace + .nodes + .iter() + .any(|n| n.node == NodeKind::NativeFallback && n.status == NodeStatus::Selected) + ); + assert!(trace.publication.is_none()); + } +} + +#[test] +fn corrupted_prefixes_and_request_controls_are_detected() { + let (mut session, mut workspace) = fixture(3, 23); + let mut evidence = None; + session + .step_with_configured_pressure_observed( + inputs(&[], 0.01), + &mut workspace, + None, + None, + None, + |_| false, + 1, + &mut evidence, + ) + .unwrap(); + let trace = evidence.unwrap(); + let mut forged = (*trace).clone(); + forged.nodes = vec![NodeReceipt { + node: NodeKind::Publication, + status: NodeStatus::Accepted, + }]; + assert!(forged.validate().is_err()); + let mut forged = (*trace).clone(); + forged.nodes.swap(2, 3); + assert_eq!(forged.validate(), Err("workflow execution ordering")); + let mut forged = (*trace).clone(); + forged.pressure = None; + assert_eq!( + forged.validate(), + Err("workflow pressure request association") + ); + let mut forged = (*trace).clone(); + forged.outcome = AttemptOutcome::Cancelled; + forged.publication = None; + forged.actual_divergence_max = None; + forged.nodes.truncate( + forged + .nodes + .iter() + .position(|n| n.node == NodeKind::PressureRequest) + .unwrap() + + 1, + ); + assert_eq!( + forged.validate(), + Err("workflow pressure diagnostic stage association") + ); + assert!(trace.matches_request(inputs(&[], 0.01))); + let forces = [BodyForce { + value: [1.0, 0.0, 0.0], + units: ForceUnits::Acceleration, + region: None, + }]; + assert!(!trace.matches_request(inputs(&forces, 0.01))); + let rates = [0.0; 27]; + let mut source = inputs(&[], 0.01); + source.source = + Some(LiquidVolumeSource::new(VolumeStamp { id: 7, version: 0 }, &rates).unwrap()); + assert_ne!(request_digest(source), trace.request_sha256); + let mut changed = inputs(&[], 0.01); + changed.volume.actual_divergence_limit = 1e-6; + assert!(!trace.matches_request(changed)); + let edges = trace.descriptor.edges(); + assert!(edges.iter().any(|e| e.from == NodeKind::Projection + && e.to == NodeKind::VolumeTransport + && e.port == PortKind::FaceVelocity)); + assert!( + !edges + .iter() + .any(|e| e.from == NodeKind::TracerTransport && e.to == NodeKind::VolumeTransport) + ); +} diff --git a/tests/saved_comparison_ui_contract.rs b/tests/saved_comparison_ui_contract.rs index 9bd1e99b..383b9d64 100644 --- a/tests/saved_comparison_ui_contract.rs +++ b/tests/saved_comparison_ui_contract.rs @@ -715,3 +715,36 @@ fn unavailable_native_fields_remain_verified_analysis_without_materialized_value assert_eq!(h.page.presentation(), Presentation::Verified); assert!(h.page.selected_pair().is_some()); } + +#[test] +fn legacy_comparison_workflows_keep_exact_sides_and_execution_evidence_unavailable() { + let root = Directory::new(); + fixture(&root.0); + let mut h = Harness::new(&root.0); + assert!( + h.page + .load_selected_workflow(Side::Reference) + .unwrap() + .is_none() + ); + h.click("open-manifest"); + h.click("compute"); + let reference = h + .page + .load_selected_workflow(Side::Reference) + .unwrap() + .unwrap(); + let candidate = h + .page + .load_selected_workflow(Side::Candidate) + .unwrap() + .unwrap(); + assert!(reference.key != candidate.key); + assert!(h.page.workflow_key(Side::Reference).as_ref() == Some(&reference.key)); + assert!(h.page.workflow_key(Side::Candidate).as_ref() == Some(&candidate.key)); + assert!(reference.key.side == Side::Reference && candidate.key.side == Side::Candidate); + assert_eq!(reference.run.identity.run_id, 41); + assert_eq!(candidate.run.identity.run_id, 42); + assert_eq!(reference.ordinal, candidate.ordinal); + assert!(reference.attempt.is_none() && candidate.attempt.is_none()); +}